peaks-cli 1.3.8 → 1.4.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 (130) hide show
  1. package/dist/src/cli/commands/core-artifact-commands.js +27 -0
  2. package/dist/src/cli/commands/project-commands.js +58 -1
  3. package/dist/src/cli/commands/request-commands.js +93 -3
  4. package/dist/src/cli/commands/retrospective-commands.d.ts +3 -0
  5. package/dist/src/cli/commands/retrospective-commands.js +113 -0
  6. package/dist/src/cli/commands/skill-scope-commands.d.ts +49 -0
  7. package/dist/src/cli/commands/skill-scope-commands.js +305 -0
  8. package/dist/src/cli/commands/workflow-commands.js +1 -1
  9. package/dist/src/cli/commands/workflow-plan-commands.d.ts +39 -0
  10. package/dist/src/cli/commands/workflow-plan-commands.js +163 -0
  11. package/dist/src/cli/program.js +8 -0
  12. package/dist/src/services/doctor/doctor-service.d.ts +40 -0
  13. package/dist/src/services/doctor/doctor-service.js +160 -0
  14. package/dist/src/services/hooks/presence-marker-detector.d.ts +16 -0
  15. package/dist/src/services/hooks/presence-marker-detector.js +105 -0
  16. package/dist/src/services/memory/project-memory-service.d.ts +19 -0
  17. package/dist/src/services/memory/project-memory-service.js +33 -0
  18. package/dist/src/services/retrospective/migrate-from-md.d.ts +37 -0
  19. package/dist/src/services/retrospective/migrate-from-md.js +528 -0
  20. package/dist/src/services/retrospective/retrospective-index.d.ts +37 -0
  21. package/dist/src/services/retrospective/retrospective-index.js +110 -0
  22. package/dist/src/services/retrospective/retrospective-show.d.ts +40 -0
  23. package/dist/src/services/retrospective/retrospective-show.js +109 -0
  24. package/dist/src/services/skill-scope/adapters/_stub-helper.d.ts +39 -0
  25. package/dist/src/services/skill-scope/adapters/_stub-helper.js +98 -0
  26. package/dist/src/services/skill-scope/adapters/claude-code.d.ts +59 -0
  27. package/dist/src/services/skill-scope/adapters/claude-code.js +304 -0
  28. package/dist/src/services/skill-scope/adapters/codex.d.ts +2 -0
  29. package/dist/src/services/skill-scope/adapters/codex.js +12 -0
  30. package/dist/src/services/skill-scope/adapters/cursor.d.ts +2 -0
  31. package/dist/src/services/skill-scope/adapters/cursor.js +13 -0
  32. package/dist/src/services/skill-scope/adapters/qoder.d.ts +2 -0
  33. package/dist/src/services/skill-scope/adapters/qoder.js +13 -0
  34. package/dist/src/services/skill-scope/adapters/tongyi.d.ts +2 -0
  35. package/dist/src/services/skill-scope/adapters/tongyi.js +13 -0
  36. package/dist/src/services/skill-scope/adapters/trae.d.ts +2 -0
  37. package/dist/src/services/skill-scope/adapters/trae.js +12 -0
  38. package/dist/src/services/skill-scope/detect.d.ts +75 -0
  39. package/dist/src/services/skill-scope/detect.js +480 -0
  40. package/dist/src/services/skill-scope/registry.d.ts +41 -0
  41. package/dist/src/services/skill-scope/registry.js +83 -0
  42. package/dist/src/services/skill-scope/source-of-truth.d.ts +44 -0
  43. package/dist/src/services/skill-scope/source-of-truth.js +118 -0
  44. package/dist/src/services/skill-scope/types.d.ts +176 -0
  45. package/dist/src/services/skill-scope/types.js +74 -0
  46. package/dist/src/services/standards/migrate-service.d.ts +63 -0
  47. package/dist/src/services/standards/migrate-service.js +193 -0
  48. package/dist/src/services/standards/project-standards-service.js +1 -23
  49. package/dist/src/services/workflow/artifact-paths.d.ts +59 -0
  50. package/dist/src/services/workflow/artifact-paths.js +127 -0
  51. package/dist/src/services/workflow/pipeline-verify-service.d.ts +6 -0
  52. package/dist/src/services/workflow/pipeline-verify-service.js +49 -4
  53. package/dist/src/services/workflow/plan-reader.d.ts +29 -0
  54. package/dist/src/services/workflow/plan-reader.js +158 -0
  55. package/dist/src/services/workflow/plan-refresher.d.ts +32 -0
  56. package/dist/src/services/workflow/plan-refresher.js +353 -0
  57. package/dist/src/services/workflow/plan-trigger-detector.d.ts +55 -0
  58. package/dist/src/services/workflow/plan-trigger-detector.js +142 -0
  59. package/dist/src/shared/format-md-compact.d.ts +32 -0
  60. package/dist/src/shared/format-md-compact.js +297 -0
  61. package/dist/src/shared/stale-policy.d.ts +67 -0
  62. package/dist/src/shared/stale-policy.js +85 -0
  63. package/dist/src/shared/version.d.ts +1 -1
  64. package/dist/src/shared/version.js +1 -1
  65. package/package.json +3 -2
  66. package/schemas/doctor-report.schema.json +2 -2
  67. package/skills/peaks-qa/SKILL.md +103 -507
  68. package/skills/peaks-qa/references/artifact-per-request.md +7 -79
  69. package/skills/peaks-qa/references/browser-validation-contracts.md +51 -0
  70. package/skills/peaks-qa/references/codegraph-regression-focus.md +5 -0
  71. package/skills/peaks-qa/references/external-capability-guidance.md +9 -0
  72. package/skills/peaks-qa/references/qa-compact-handoff.md +3 -0
  73. package/skills/peaks-qa/references/qa-context-governance.md +24 -0
  74. package/skills/peaks-qa/references/qa-fanout-contract.md +8 -0
  75. package/skills/peaks-qa/references/qa-gstack-integration.md +7 -0
  76. package/skills/peaks-qa/references/qa-local-artifacts.md +3 -0
  77. package/skills/peaks-qa/references/qa-matt-pocock-integration.md +9 -0
  78. package/skills/peaks-qa/references/qa-perf-test-plan.md +67 -0
  79. package/skills/peaks-qa/references/qa-refactor-role.md +3 -0
  80. package/skills/peaks-qa/references/qa-runbook.md +74 -0
  81. package/skills/peaks-qa/references/qa-security-test-plan.md +73 -0
  82. package/skills/peaks-qa/references/qa-skill-presence.md +22 -0
  83. package/skills/peaks-qa/references/qa-standards-preflight.md +8 -0
  84. package/skills/peaks-qa/references/qa-sub-agent-dispatch.md +38 -0
  85. package/skills/peaks-qa/references/qa-transition-gates.md +83 -0
  86. package/skills/peaks-qa/references/requirement-boundary-recheck.md +9 -0
  87. package/skills/peaks-qa/references/test-case-generation.md +27 -0
  88. package/skills/peaks-qa/references/test-report-output.md +14 -0
  89. package/skills/peaks-rd/SKILL.md +85 -612
  90. package/skills/peaks-rd/references/artifact-and-standards-output.md +9 -0
  91. package/skills/peaks-rd/references/artifact-per-request.md +20 -0
  92. package/skills/peaks-rd/references/browser-self-test-contracts.md +29 -0
  93. package/skills/peaks-rd/references/codegraph-project-analysis.md +5 -0
  94. package/skills/peaks-rd/references/compact-handoff.md +3 -0
  95. package/skills/peaks-rd/references/external-references.md +11 -0
  96. package/skills/peaks-rd/references/frontend-project-generation.md +11 -0
  97. package/skills/peaks-rd/references/library-version-awareness.md +30 -0
  98. package/skills/peaks-rd/references/mandatory-perf-baseline.md +42 -0
  99. package/skills/peaks-rd/references/mandatory-tech-doc.md +18 -0
  100. package/skills/peaks-rd/references/matt-pocock-integration.md +11 -0
  101. package/skills/peaks-rd/references/mock-data-placement.md +40 -0
  102. package/skills/peaks-rd/references/parallel-review-fanout.md +81 -0
  103. package/skills/peaks-rd/references/rd-context-governance.md +36 -0
  104. package/skills/peaks-rd/references/rd-gstack-integration.md +16 -0
  105. package/skills/peaks-rd/references/rd-runbook.md +125 -0
  106. package/skills/peaks-rd/references/rd-standards-preflight.md +8 -0
  107. package/skills/peaks-rd/references/rd-sub-agent-dispatch.md +39 -0
  108. package/skills/peaks-rd/references/rd-transition-gates.md +1 -1
  109. package/skills/peaks-rd/references/skill-presence-and-title.md +22 -0
  110. package/skills/peaks-solo/SKILL.md +87 -595
  111. package/skills/peaks-solo/references/anchoring-and-session-info.md +25 -0
  112. package/skills/peaks-solo/references/boundaries.md +21 -0
  113. package/skills/peaks-solo/references/codegraph-orchestration.md +5 -0
  114. package/skills/peaks-solo/references/completion-handoff.md +16 -0
  115. package/skills/peaks-solo/references/context-governance.md +51 -0
  116. package/skills/peaks-solo/references/external-references.md +17 -0
  117. package/skills/peaks-solo/references/frontend-only-mode.md +14 -0
  118. package/skills/peaks-solo/references/gstack-integration.md +7 -0
  119. package/skills/peaks-solo/references/local-artifact-workspace.md +79 -0
  120. package/skills/peaks-solo/references/micro-cycle.md +68 -0
  121. package/skills/peaks-solo/references/mode-selection.md +21 -0
  122. package/skills/peaks-solo/references/openspec-workflow.md +43 -0
  123. package/skills/peaks-solo/references/project-memory-loading.md +17 -0
  124. package/skills/peaks-solo/references/quality-gate-cheatsheet.md +13 -0
  125. package/skills/peaks-solo/references/resume-detection.md +63 -0
  126. package/skills/peaks-solo/references/runbook.md +1 -1
  127. package/skills/peaks-solo/references/skill-presence-and-title.md +31 -0
  128. package/skills/peaks-solo/references/standards-preflight.md +23 -0
  129. package/skills/peaks-solo/references/sub-agent-dispatch.md +46 -0
  130. package/skills/peaks-solo/references/swarm-dispatch-contract.md +56 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `peaks workflow plan detect-trigger` — slice 025 (Security + Perf
3
+ * Plan/Result split).
4
+ *
5
+ * Compares the current project state (filesystem + package.json) to the
6
+ * last-refresh fingerprint and returns whether a plan refresh is
7
+ * warranted. Five trigger rules, locked decision 1 excludes
8
+ * devDependencies.
9
+ *
10
+ * The slice's "diff" is supplied as a `SliceDiff` object; when not
11
+ * supplied, the detector scans the project directly (the same scan the
12
+ * refresh plan performs).
13
+ */
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { fail, ok } from '../../shared/result.js';
17
+ /** F-1 (slice 025 security): canonical request-id shape. */
18
+ export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19
+ const SENSITIVE_SERVICE_DIRS = ['auth', 'security', 'secrets', 'payments', 'filesystem'];
20
+ function readPackageJson(projectRoot) {
21
+ const path = join(projectRoot, 'package.json');
22
+ if (!existsSync(path))
23
+ return null;
24
+ try {
25
+ return JSON.parse(readFileSync(path, 'utf8'));
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function isAuthFile(path) {
32
+ return /auth.*\.ts$|\.ts$/i.test(path) && /auth/i.test(path);
33
+ }
34
+ function isHotPathFile(path) {
35
+ return /router\.ts$|commands\/.*-commands\.ts$/i.test(path);
36
+ }
37
+ function isSensitiveServiceFile(path) {
38
+ if (!/^src\/services\/(auth|security|secrets|payments|filesystem)\//.test(path))
39
+ return false;
40
+ return /\.ts$/.test(path);
41
+ }
42
+ function freshScan(projectRoot) {
43
+ const pkg = readPackageJson(projectRoot);
44
+ const newFiles = [];
45
+ const root = join(projectRoot, 'src');
46
+ if (existsSync(root)) {
47
+ const stack = [root];
48
+ while (stack.length > 0) {
49
+ const dir = stack.pop();
50
+ if (dir === undefined)
51
+ continue;
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(dir, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ for (const entry of entries) {
60
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist')
61
+ continue;
62
+ const full = join(dir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ stack.push(full);
65
+ }
66
+ else if (entry.isFile() && entry.name.endsWith('.ts')) {
67
+ // For the purposes of trigger detection, every TS file is a
68
+ // candidate; the gate logic decides which class it falls into.
69
+ newFiles.push(full);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return {
75
+ packageJson: {
76
+ dependencies: { added: Object.keys(pkg?.dependencies ?? {}), removed: [], changed: [] },
77
+ optionalDependencies: { added: Object.keys(pkg?.optionalDependencies ?? {}), removed: [], changed: [] },
78
+ devDependencies: { added: Object.keys(pkg?.devDependencies ?? {}), removed: [], changed: [] }
79
+ },
80
+ newFiles: newFiles.sort(),
81
+ changedFiles: []
82
+ };
83
+ }
84
+ function anyAddedDeps(diff) {
85
+ const dep = diff.packageJson?.dependencies?.added ?? [];
86
+ const opt = diff.packageJson?.optionalDependencies?.added ?? [];
87
+ return dep.length > 0 || opt.length > 0;
88
+ }
89
+ function findNewAuthFile(diff) {
90
+ for (const f of diff.newFiles ?? []) {
91
+ if (isAuthFile(f))
92
+ return f;
93
+ }
94
+ return null;
95
+ }
96
+ function findNewSensitiveServiceFile(diff) {
97
+ for (const f of diff.newFiles ?? []) {
98
+ if (isSensitiveServiceFile(f))
99
+ return f;
100
+ }
101
+ return null;
102
+ }
103
+ function findNewHotPathFile(diff) {
104
+ for (const f of diff.newFiles ?? []) {
105
+ if (isHotPathFile(f))
106
+ return f;
107
+ }
108
+ return null;
109
+ }
110
+ export function detectTrigger(args) {
111
+ // F-1 (slice 025 security): reject traversal/separator payloads at
112
+ // the service boundary so every caller (CLI, skill, integration test)
113
+ // gets the same rejection shape.
114
+ if (!REQUEST_ID_PATTERN.test(args.rid)) {
115
+ return fail('workflow.plan.detect-trigger', 'INVALID_RID', 'request id must match [A-Za-z0-9][A-Za-z0-9._-]*', {
116
+ triggered: false,
117
+ reason: 'no-triggering-change'
118
+ });
119
+ }
120
+ if (args.manualOverride === true) {
121
+ return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'manual-override' });
122
+ }
123
+ const diff = args.diff ?? freshScan(args.project);
124
+ // Rule 1: new top-level dependency in `dependencies` or `optionalDependencies`
125
+ // (devDependencies explicitly excluded per locked decision 1).
126
+ if (anyAddedDeps(diff)) {
127
+ return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'new-dependency' });
128
+ }
129
+ // Rule 2: new file under src/services/{auth,security,secrets,payments,filesystem}/
130
+ if (findNewSensitiveServiceFile(diff) !== null) {
131
+ return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'auth-surface-added' });
132
+ }
133
+ // Rule 3: new *auth*.ts file anywhere in src/
134
+ if (findNewAuthFile(diff) !== null) {
135
+ return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'auth-surface-added' });
136
+ }
137
+ // Rule 4: new endpoint / route registration
138
+ if (findNewHotPathFile(diff) !== null) {
139
+ return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'hot-path-added' });
140
+ }
141
+ return ok('workflow.plan.detect-trigger', { triggered: false, reason: 'no-triggering-change' });
142
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * format-md-compact — single source of truth for whitespace / decoration
3
+ * normalization across every CLI body-output path.
4
+ *
5
+ * Slice 023 (R3) — `peaks project memories:show`, `peaks retrospective show`,
6
+ * and `peaks request show` (per-artifact) all funnel their `body` field through
7
+ * this helper so the LLM-consumed output is free of blank-line padding,
8
+ * decorative `---` rules, and frontmatter `description:` field-name repeats
9
+ * while preserving every semantically meaningful construct (code fences,
10
+ * setext heading underlines, GFM table syntax, frontmatter, list indentation,
11
+ * inline emphasis, code spans).
12
+ *
13
+ * Pure function: no fs, no I/O. Easy to unit-test (see
14
+ * `tests/unit/shared/format-md-compact.test.ts`).
15
+ */
16
+ export interface FormatMdCompactOptions {
17
+ /** Preserve code-fence (``` ... ```) and frontmatter (--- ... ---) content. Default true. */
18
+ preserveCodeBlocks?: boolean;
19
+ /** Preserve setext heading underlines (===, --- under a text line). Default true. */
20
+ preserveSetextHeadings?: boolean;
21
+ /** Preserve GFM table syntax (| ... | rows). Default true. */
22
+ preserveTables?: boolean;
23
+ /** Collapse 3+ blank lines to 1. Default true. */
24
+ collapseBlankLines?: boolean;
25
+ /** Strip trailing whitespace per line. Default true. */
26
+ stripTrailingWhitespace?: boolean;
27
+ /** Strip decorative `---` lines (lines surrounded by blanks OR trailing final line). Default true. */
28
+ stripDecorativeHorizontalRules?: boolean;
29
+ /** Strip frontmatter `description:` field-name repeat when same value already in body header. Default true. */
30
+ stripFrontmatterDescriptionRepeat?: boolean;
31
+ }
32
+ export declare function formatMdCompact(input: string, options?: FormatMdCompactOptions): string;
@@ -0,0 +1,297 @@
1
+ /**
2
+ * format-md-compact — single source of truth for whitespace / decoration
3
+ * normalization across every CLI body-output path.
4
+ *
5
+ * Slice 023 (R3) — `peaks project memories:show`, `peaks retrospective show`,
6
+ * and `peaks request show` (per-artifact) all funnel their `body` field through
7
+ * this helper so the LLM-consumed output is free of blank-line padding,
8
+ * decorative `---` rules, and frontmatter `description:` field-name repeats
9
+ * while preserving every semantically meaningful construct (code fences,
10
+ * setext heading underlines, GFM table syntax, frontmatter, list indentation,
11
+ * inline emphasis, code spans).
12
+ *
13
+ * Pure function: no fs, no I/O. Easy to unit-test (see
14
+ * `tests/unit/shared/format-md-compact.test.ts`).
15
+ */
16
+ const FENCE_TRIPLE_BACKTICK = '```';
17
+ const FENCE_TRIPLE_TILDE = '~~~';
18
+ const FENCE_MARKER_RE = /^(```+|~~~+)/;
19
+ const TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
20
+ const TABLE_ALIGN_ROW_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/;
21
+ const ATX_HEADING_RE = /^#{1,6}\s/;
22
+ const HEADING_LINE_RE = /^\S/;
23
+ const SETEXT_UNDERLINE_RE = /^=+\s*$|^-{2,}\s*$/;
24
+ export function formatMdCompact(input, options = {}) {
25
+ if (input.length === 0)
26
+ return input;
27
+ const opts = {
28
+ preserveCodeBlocks: options.preserveCodeBlocks ?? true,
29
+ preserveSetextHeadings: options.preserveSetextHeadings ?? true,
30
+ preserveTables: options.preserveTables ?? true,
31
+ collapseBlankLines: options.collapseBlankLines ?? true,
32
+ stripTrailingWhitespace: options.stripTrailingWhitespace ?? true,
33
+ stripDecorativeHorizontalRules: options.stripDecorativeHorizontalRules ?? true,
34
+ stripFrontmatterDescriptionRepeat: options.stripFrontmatterDescriptionRepeat ?? true
35
+ };
36
+ // 1. Split frontmatter from body (if any). The YAML block lives between
37
+ // the first line `---` and the second line `---`. We carry it through
38
+ // unchanged.
39
+ const fm = splitFrontmatter(input);
40
+ const body = fm.body;
41
+ // 2. Walk the body line-by-line, applying the protected-zone rules.
42
+ const normalizedBody = normalizeBody(body, opts);
43
+ // 3. Optional frontmatter `description:` repeat strip. The body's
44
+ // first ATX heading is compared to the frontmatter `description:`
45
+ // value; if the description text repeats the heading, the leading
46
+ // paragraph is dropped. Implemented as a one-shot post-pass.
47
+ const finalBody = opts.stripFrontmatterDescriptionRepeat
48
+ ? stripDescriptionRepeat(normalizedBody, fm.description)
49
+ : normalizedBody;
50
+ // 4. Re-assemble. The frontmatter stays verbatim; the body carries the
51
+ // normalized text. Match the original layout: if the input had
52
+ // frontmatter, output is `frontmatter + blank + body`; otherwise
53
+ // the body is the whole output.
54
+ if (fm.raw === '') {
55
+ return finalBody;
56
+ }
57
+ return fm.raw + '\n' + finalBody;
58
+ }
59
+ function splitFrontmatter(input) {
60
+ // Normalize line endings so Windows \r\n doesn't confuse the leading-marker check.
61
+ const normalized = input.replace(/\r\n/g, '\n');
62
+ const lines = normalized.split('\n');
63
+ if (lines[0] !== '---') {
64
+ return { raw: '', description: null, body: normalized };
65
+ }
66
+ let closeIndex = -1;
67
+ for (let index = 1; index < lines.length; index += 1) {
68
+ if (lines[index] === '---') {
69
+ closeIndex = index;
70
+ break;
71
+ }
72
+ }
73
+ if (closeIndex < 0) {
74
+ return { raw: '', description: null, body: normalized };
75
+ }
76
+ const raw = lines.slice(0, closeIndex + 1).join('\n');
77
+ // Body = everything after the closing `---` (preserving one optional blank line).
78
+ const bodyLines = lines.slice(closeIndex + 1);
79
+ while (bodyLines.length > 0 && bodyLines[0] === '') {
80
+ bodyLines.shift();
81
+ }
82
+ const body = bodyLines.join('\n');
83
+ // Extract the `description:` field. Walk the YAML block; pull the value
84
+ // as a single-line string. Multi-line (`|`) or folded (`>`) scalars are
85
+ // joined with a single space — the description is a short summary, the
86
+ // exact whitespace inside the block scalar is not preserved.
87
+ const frontmatterLines = lines.slice(1, closeIndex);
88
+ const description = extractFrontmatterDescription(frontmatterLines);
89
+ return { raw, description, body };
90
+ }
91
+ function extractFrontmatterDescription(frontmatterLines) {
92
+ for (let index = 0; index < frontmatterLines.length; index += 1) {
93
+ const line = frontmatterLines[index] ?? '';
94
+ const match = line.match(/^description:\s*(.*)$/);
95
+ if (match === null)
96
+ continue;
97
+ const inline = (match[1] ?? '').trim();
98
+ if (inline === '|' || inline === '>') {
99
+ const collected = [];
100
+ for (let inner = index + 1; inner < frontmatterLines.length; inner += 1) {
101
+ const innerLine = frontmatterLines[inner] ?? '';
102
+ if (/^[A-Za-z0-9_-]+:/.test(innerLine))
103
+ break;
104
+ collected.push(innerLine.replace(/^\s{2}/, ''));
105
+ }
106
+ return collected.join(' ').trim();
107
+ }
108
+ // Strip surrounding quotes if the value is a quoted scalar.
109
+ return inline.replace(/^['"]|['"]$/g, '');
110
+ }
111
+ return null;
112
+ }
113
+ function normalizeBody(body, opts) {
114
+ // Walk line-by-line, tracking the protected-zone flags and emitting a
115
+ // transformed line stream. We keep three flag bits:
116
+ // insideFence: toggled on ``` / ~~~ lines
117
+ // insideFrontmatterYAML: handled by splitFrontmatter; body has none.
118
+ // insideTable: detected by leading-pipe; carried until first non-pipe
119
+ // line.
120
+ const lines = body.split('\n');
121
+ // 1. Pre-pass: compute the `setextUnderlined` markers so the decorative
122
+ // `---` strip can know when a line is a setext H2 underline (semantic)
123
+ // vs a decoration.
124
+ const setextUnderlined = computeSetextUnderlines(lines);
125
+ const protection = [];
126
+ let insideFence = false;
127
+ let insideTable = false;
128
+ for (let index = 0; index < lines.length; index += 1) {
129
+ const line = lines[index] ?? '';
130
+ if (opts.preserveCodeBlocks && isFenceOpenLine(line) && !isFenceCloseLine(line, insideFence)) {
131
+ insideFence = !insideFence;
132
+ protection.push('fence');
133
+ continue;
134
+ }
135
+ if (insideFence) {
136
+ protection.push('fence');
137
+ continue;
138
+ }
139
+ if (opts.preserveSetextHeadings && setextUnderlined.has(index)) {
140
+ protection.push('setext');
141
+ continue;
142
+ }
143
+ if (opts.preserveTables && isTableLine(line)) {
144
+ // Entering / continuing a table — first row that looks like a table
145
+ // starts the table zone; the alignment row (|---|) is included.
146
+ insideTable = true;
147
+ protection.push('table');
148
+ continue;
149
+ }
150
+ if (insideTable && !isTableLine(line) && line.trim() !== '') {
151
+ insideTable = false;
152
+ }
153
+ if (insideTable) {
154
+ protection.push('table');
155
+ continue;
156
+ }
157
+ protection.push('plain');
158
+ }
159
+ // 3. Per-line transforms: strip trailing whitespace outside protected
160
+ // zones; strip decorative `---` outside setext / table / fence zones.
161
+ const out = [];
162
+ for (let index = 0; index < lines.length; index += 1) {
163
+ const line = lines[index] ?? '';
164
+ const zone = protection[index] ?? 'plain';
165
+ let transformed = line;
166
+ if (zone !== 'fence' && opts.stripTrailingWhitespace) {
167
+ transformed = transformed.replace(/[ \t]+$/u, '');
168
+ }
169
+ if (zone !== 'fence' &&
170
+ zone !== 'setext' &&
171
+ zone !== 'table' &&
172
+ opts.stripDecorativeHorizontalRules) {
173
+ if (isDecorativeHorizontalRule(transformed)) {
174
+ // Mark for removal: push a sentinel. The blank-line collapse step
175
+ // will merge surrounding blanks.
176
+ transformed = '__PEAKS_HR_REMOVED__';
177
+ }
178
+ }
179
+ out.push(transformed);
180
+ }
181
+ // 4. Drop the sentinel lines (decorative `---` after classification).
182
+ let collapsed = out.filter((line) => line !== '__PEAKS_HR_REMOVED__');
183
+ // 5. Collapse 3+ consecutive blank lines into 1. Two blank lines
184
+ // between two non-blank lines are also collapsed to 1 (matches PRD
185
+ // R2's "decorative `---` is redundant" rule — the original visual
186
+ // gap that surrounded the `---` collapses with it).
187
+ if (opts.collapseBlankLines) {
188
+ collapsed = collapseMultiBlanks(collapsed);
189
+ }
190
+ return collapsed.join('\n');
191
+ }
192
+ function isFenceOpenLine(line) {
193
+ return FENCE_MARKER_RE.test(line);
194
+ }
195
+ function isFenceCloseLine(line, insideFence) {
196
+ // The `isFenceOpenLine` already returned true for this line, so it
197
+ // starts with ``` or ~~~. A "close" line is one that opens a new fence
198
+ // of the *same* length. We approximate by treating any opener as a close
199
+ // when we are currently inside a fence.
200
+ return insideFence;
201
+ }
202
+ function isTableLine(line) {
203
+ return TABLE_ROW_RE.test(line) || TABLE_ALIGN_ROW_RE.test(line);
204
+ }
205
+ function computeSetextUnderlines(lines) {
206
+ // A `===` or `---` line is a setext heading underline ONLY when it sits
207
+ // directly under a non-blank, non-ATX text line (no blank line between
208
+ // them). The presence of a blank line between the text and the rule
209
+ // disqualifies it (a blank-separated `---` is decoration, not setext).
210
+ const result = new Set();
211
+ for (let index = 1; index < lines.length; index += 1) {
212
+ const line = lines[index] ?? '';
213
+ if (!SETEXT_UNDERLINE_RE.test(line))
214
+ continue;
215
+ const prev = lines[index - 1] ?? '';
216
+ if (prev === '')
217
+ continue;
218
+ if (ATX_HEADING_RE.test(prev))
219
+ continue;
220
+ if (!HEADING_LINE_RE.test(prev))
221
+ continue;
222
+ result.add(index);
223
+ }
224
+ return result;
225
+ }
226
+ function isDecorativeHorizontalRule(line) {
227
+ // A line that is exactly `---` (or any number of `-` chars) is a
228
+ // candidate horizontal rule. The caller has already excluded setext
229
+ // and table contexts via the `protection` array.
230
+ return /^-+$/.test(line);
231
+ }
232
+ function collapseMultiBlanks(lines) {
233
+ const result = [];
234
+ let blankRun = 0;
235
+ for (const line of lines) {
236
+ if (line === '') {
237
+ blankRun += 1;
238
+ // 1 blank line is the cap. Drop the rest.
239
+ if (blankRun <= 1) {
240
+ result.push(line);
241
+ }
242
+ continue;
243
+ }
244
+ blankRun = 0;
245
+ result.push(line);
246
+ }
247
+ return result;
248
+ }
249
+ function stripDescriptionRepeat(body, description) {
250
+ if (description === null || description.length === 0)
251
+ return body;
252
+ // Find the first ATX heading line. If it equals the frontmatter
253
+ // description text, drop it. Also drop a description-matching paragraph
254
+ // that immediately follows the heading (PRD's "frontmatter description
255
+ // already in body header" case). All other content is preserved.
256
+ const lines = body.split('\n');
257
+ if (lines.length === 0)
258
+ return body;
259
+ let firstHeadingIndex = -1;
260
+ for (let index = 0; index < lines.length; index += 1) {
261
+ const line = lines[index] ?? '';
262
+ if (line.trim() === '')
263
+ continue;
264
+ if (ATX_HEADING_RE.test(line)) {
265
+ firstHeadingIndex = index;
266
+ }
267
+ break;
268
+ }
269
+ if (firstHeadingIndex < 0)
270
+ return body;
271
+ const headingText = (lines[firstHeadingIndex] ?? '').replace(/^#{1,6}\s+/, '').trim();
272
+ if (headingText !== description)
273
+ return body;
274
+ // Walk past the heading, then past any blank lines, then past the
275
+ // matching description paragraph (if present). Anything after the
276
+ // description paragraph is the body we want to keep.
277
+ const tail = lines.slice(firstHeadingIndex + 1);
278
+ let cursor = 0;
279
+ // Skip blanks.
280
+ while (cursor < tail.length && (tail[cursor] ?? '') === '') {
281
+ cursor += 1;
282
+ }
283
+ // Read the first non-blank paragraph.
284
+ const paragraphStart = cursor;
285
+ while (cursor < tail.length && (tail[cursor] ?? '') !== '') {
286
+ cursor += 1;
287
+ }
288
+ const paragraph = tail.slice(paragraphStart, cursor);
289
+ if (paragraph.join(' ').trim() !== description) {
290
+ // The paragraph doesn't match the description — don't drop it.
291
+ cursor = paragraphStart;
292
+ }
293
+ const kept = tail.slice(cursor);
294
+ if (kept.length === 0)
295
+ return '';
296
+ return kept.join('\n');
297
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * stale-policy — pure helper for stale detection and filtering on memory
3
+ * and (future) retrospective entries.
4
+ *
5
+ * Slice 023 (R3) applies this to memory only (`peaks project memories:show`
6
+ * and the underlying `readMemoryIndex` load path). The retrospective index
7
+ * loader shares the same shape (`updatedAt: string`) and will be wired to
8
+ * the same helper in a future slice.
9
+ *
10
+ * Behavior:
11
+ * - `isStale(updatedAt)` returns true when (now - updatedAt) >
12
+ * thresholdDays * 86_400_000. Strict greater-than: a 30-day-old
13
+ * entry is NOT stale.
14
+ * - `applyStalePolicy(entries)` adds `stale: boolean` and
15
+ * `ageDays: number` to every entry (computed at CLI load time, never
16
+ * persisted to source `.md` files) and filters out entries where
17
+ * `stale === true` unless `includeStale: true` is passed.
18
+ * - Missing `updatedAt` is treated as fresh (`stale: false`,
19
+ * `ageDays: 0`); we never throw, so older index.json entries that
20
+ * lack the field stay loadable.
21
+ */
22
+ export interface StalePolicyOptions {
23
+ /** Reference clock (ms since epoch). Defaults to Date.now(). Injected for testability. */
24
+ now?: number;
25
+ /** Threshold in days. Default 30. */
26
+ thresholdDays?: number;
27
+ /** When true, keep stale entries in the returned array (with `stale: true` set). Default false. */
28
+ includeStale?: boolean;
29
+ }
30
+ export interface StaleAnnotated<T> {
31
+ stale: boolean;
32
+ ageDays: number;
33
+ }
34
+ export type StaleAnnotatedEntry<T> = T & StaleAnnotated<T>;
35
+ export interface StalePolicyResult<T> {
36
+ /** Filtered array, with stale entries removed (unless includeStale=true). */
37
+ entries: StaleAnnotatedEntry<T>[];
38
+ /** Count of entries dropped as stale. */
39
+ droppedCount: number;
40
+ /** Total count before filtering. */
41
+ totalCount: number;
42
+ }
43
+ export declare const DAY_MS = 86400000;
44
+ export declare const DEFAULT_STALE_DAYS = 30;
45
+ /**
46
+ * Returns true when the entry is older than `thresholdDays`. Strict
47
+ * greater-than: an entry exactly at the threshold is NOT stale.
48
+ *
49
+ * A missing or unparseable `updatedAt` is treated as fresh (false). This
50
+ * is the "defensive — older index.json entries may lack the field" rule
51
+ * from PRD R4.
52
+ */
53
+ export declare function isStale(updatedAt: string | undefined | null, options?: StalePolicyOptions): boolean;
54
+ /**
55
+ * Age in days between `updatedAt` and `now` (default Date.now()).
56
+ * Returns 0 for a missing / unparseable `updatedAt`.
57
+ */
58
+ export declare function ageInDays(updatedAt: string | undefined | null, now?: number): number;
59
+ /**
60
+ * Apply the stale policy to a list of entries. Each entry is augmented
61
+ * with `stale: boolean` and `ageDays: number` (immutably — a fresh
62
+ * object is returned per entry). Stale entries are dropped from
63
+ * `entries` unless `includeStale: true` is passed.
64
+ */
65
+ export declare function applyStalePolicy<T extends {
66
+ updatedAt?: string | null;
67
+ }>(entries: T[], options?: StalePolicyOptions): StalePolicyResult<T>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * stale-policy — pure helper for stale detection and filtering on memory
3
+ * and (future) retrospective entries.
4
+ *
5
+ * Slice 023 (R3) applies this to memory only (`peaks project memories:show`
6
+ * and the underlying `readMemoryIndex` load path). The retrospective index
7
+ * loader shares the same shape (`updatedAt: string`) and will be wired to
8
+ * the same helper in a future slice.
9
+ *
10
+ * Behavior:
11
+ * - `isStale(updatedAt)` returns true when (now - updatedAt) >
12
+ * thresholdDays * 86_400_000. Strict greater-than: a 30-day-old
13
+ * entry is NOT stale.
14
+ * - `applyStalePolicy(entries)` adds `stale: boolean` and
15
+ * `ageDays: number` to every entry (computed at CLI load time, never
16
+ * persisted to source `.md` files) and filters out entries where
17
+ * `stale === true` unless `includeStale: true` is passed.
18
+ * - Missing `updatedAt` is treated as fresh (`stale: false`,
19
+ * `ageDays: 0`); we never throw, so older index.json entries that
20
+ * lack the field stay loadable.
21
+ */
22
+ export const DAY_MS = 86_400_000;
23
+ export const DEFAULT_STALE_DAYS = 30;
24
+ /**
25
+ * Returns true when the entry is older than `thresholdDays`. Strict
26
+ * greater-than: an entry exactly at the threshold is NOT stale.
27
+ *
28
+ * A missing or unparseable `updatedAt` is treated as fresh (false). This
29
+ * is the "defensive — older index.json entries may lack the field" rule
30
+ * from PRD R4.
31
+ */
32
+ export function isStale(updatedAt, options = {}) {
33
+ const parsed = parseUpdatedAt(updatedAt);
34
+ if (parsed === null)
35
+ return false;
36
+ const now = options.now ?? Date.now();
37
+ const thresholdDays = options.thresholdDays ?? DEFAULT_STALE_DAYS;
38
+ return now - parsed > thresholdDays * DAY_MS;
39
+ }
40
+ /**
41
+ * Age in days between `updatedAt` and `now` (default Date.now()).
42
+ * Returns 0 for a missing / unparseable `updatedAt`.
43
+ */
44
+ export function ageInDays(updatedAt, now = Date.now()) {
45
+ const parsed = parseUpdatedAt(updatedAt);
46
+ if (parsed === null)
47
+ return 0;
48
+ return Math.max(0, Math.floor((now - parsed) / DAY_MS));
49
+ }
50
+ /**
51
+ * Apply the stale policy to a list of entries. Each entry is augmented
52
+ * with `stale: boolean` and `ageDays: number` (immutably — a fresh
53
+ * object is returned per entry). Stale entries are dropped from
54
+ * `entries` unless `includeStale: true` is passed.
55
+ */
56
+ export function applyStalePolicy(entries, options = {}) {
57
+ const now = options.now ?? Date.now();
58
+ const thresholdDays = options.thresholdDays ?? DEFAULT_STALE_DAYS;
59
+ const includeStale = options.includeStale ?? false;
60
+ const annotated = entries.map((entry) => {
61
+ const parsed = parseUpdatedAt(entry.updatedAt ?? null);
62
+ if (parsed === null) {
63
+ return { ...entry, stale: false, ageDays: 0 };
64
+ }
65
+ const stale = now - parsed > thresholdDays * DAY_MS;
66
+ const ageDays = Math.max(0, Math.floor((now - parsed) / DAY_MS));
67
+ return { ...entry, stale, ageDays };
68
+ });
69
+ const filtered = includeStale
70
+ ? annotated
71
+ : annotated.filter((entry) => !entry.stale);
72
+ return {
73
+ entries: filtered,
74
+ droppedCount: annotated.length - filtered.length,
75
+ totalCount: annotated.length
76
+ };
77
+ }
78
+ function parseUpdatedAt(value) {
79
+ if (value === null || value === undefined || value === '')
80
+ return null;
81
+ const parsed = Date.parse(value);
82
+ if (Number.isNaN(parsed))
83
+ return null;
84
+ return parsed;
85
+ }
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.3.8";
1
+ export declare const CLI_VERSION = "1.4.0";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.3.8";
1
+ export const CLI_VERSION = "1.4.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "description": "Cross-AI-IDE workflow-gating CLI + skill family (Claude Code shipped, Trae in progress; Codex / Cursor / Qoder / Tongyi Lingma on the roadmap).",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -46,8 +46,9 @@
46
46
  "pretest": "node ./scripts/sync-version.mjs",
47
47
  "test": "vitest run",
48
48
  "test:coverage": "vitest run --coverage",
49
+ "test:coverage:workflow": "vitest run --coverage --coverage.include='src/services/workflow/plan-*.ts' tests/unit/services/workflow/",
49
50
  "pretest:coverage": "node ./scripts/pretest-coverage.mjs",
50
- "typecheck": "tsc -p tsconfig.json --noEmit"
51
+ "typecheck": "tsc -p tsconfig.json --noEmit && vitest run --coverage --coverage.include='src/services/workflow/plan-*.ts' tests/unit/services/workflow/"
51
52
  },
52
53
  "engines": {
53
54
  "node": ">=20.0.0"
@@ -13,8 +13,8 @@
13
13
  "properties": {
14
14
  "id": {
15
15
  "type": "string",
16
- "pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|statusline|schema|config|doctor-self|capability|build):[A-Za-z0-9][A-Za-z0-9._-]*$",
17
- "description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness/workspace), statusline:<topic> (out-of-band Claude Code statusLine — install/runtime), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version), build:<topic> (build-hygiene checks — dist/source version consistency)."
16
+ "pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|statusline|schema|config|doctor-self|capability|build|integration):[A-Za-z0-9][A-Za-z0-9._-]*$",
17
+ "description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness/workspace), statusline:<topic> (out-of-band Claude Code statusLine — install/runtime), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version), build:<topic> (build-hygiene checks — dist/source version consistency), integration:<name> (third-party integration probe — e.g. gateguard-fact-force PreToolUse hook conflict on .peaks/**)."
18
18
  },
19
19
  "ok": { "type": "boolean" },
20
20
  "message": { "type": "string", "minLength": 1 }