peaks-cli 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/commands/core-artifact-commands.js +21 -0
- package/dist/src/cli/commands/memory-commands.d.ts +13 -0
- package/dist/src/cli/commands/memory-commands.js +60 -0
- package/dist/src/cli/commands/migrate-1-4-1-command.d.ts +11 -0
- package/dist/src/cli/commands/migrate-1-4-1-command.js +34 -0
- package/dist/src/cli/commands/retrospective-commands.d.ts +9 -0
- package/dist/src/cli/commands/retrospective-commands.js +58 -0
- package/dist/src/cli/commands/workspace-commands.js +8 -0
- package/dist/src/cli/program.js +16 -22
- package/dist/src/services/fuzzy-matching/fuzzy-match-service.d.ts +15 -0
- package/dist/src/services/fuzzy-matching/fuzzy-match-service.js +56 -0
- package/dist/src/services/fuzzy-matching/types.d.ts +20 -0
- package/dist/src/services/fuzzy-matching/types.js +1 -0
- package/dist/src/services/memory/memory-search-service.d.ts +61 -0
- package/dist/src/services/memory/memory-search-service.js +80 -0
- package/dist/src/services/recommendations/capability-seed-items.js +0 -1
- package/dist/src/services/recommendations/capability-seed-mappings.js +0 -1
- package/dist/src/services/recommendations/capability-seed-sources.js +0 -1
- package/dist/src/services/retrospective/retrospective-search-service.d.ts +37 -0
- package/dist/src/services/retrospective/retrospective-search-service.js +75 -0
- package/dist/src/services/standards/project-context.d.ts +1 -1
- package/dist/src/services/standards/project-context.js +0 -4
- package/dist/src/services/standards/project-standards-service.js +1 -3
- package/dist/src/services/workspace/migrate-1-4-1-service.d.ts +44 -0
- package/dist/src/services/workspace/migrate-1-4-1-service.js +195 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +3 -7
- package/skills/peaks-solo/SKILL.md +1 -1
- package/skills/peaks-solo/references/completion-handoff.md +3 -1
- package/dist/src/cli/commands/shadcn-commands.d.ts +0 -3
- package/dist/src/cli/commands/shadcn-commands.js +0 -35
- package/dist/src/cli/commands/skill-scope-commands.d.ts +0 -49
- package/dist/src/cli/commands/skill-scope-commands.js +0 -305
- package/dist/src/services/shadcn/shadcn-service.d.ts +0 -27
- package/dist/src/services/shadcn/shadcn-service.js +0 -128
- package/dist/src/services/skill-scope/adapters/_stub-helper.d.ts +0 -39
- package/dist/src/services/skill-scope/adapters/_stub-helper.js +0 -98
- package/dist/src/services/skill-scope/adapters/claude-code.d.ts +0 -59
- package/dist/src/services/skill-scope/adapters/claude-code.js +0 -304
- package/dist/src/services/skill-scope/adapters/codex.d.ts +0 -2
- package/dist/src/services/skill-scope/adapters/codex.js +0 -12
- package/dist/src/services/skill-scope/adapters/cursor.d.ts +0 -2
- package/dist/src/services/skill-scope/adapters/cursor.js +0 -13
- package/dist/src/services/skill-scope/adapters/qoder.d.ts +0 -2
- package/dist/src/services/skill-scope/adapters/qoder.js +0 -13
- package/dist/src/services/skill-scope/adapters/tongyi.d.ts +0 -2
- package/dist/src/services/skill-scope/adapters/tongyi.js +0 -13
- package/dist/src/services/skill-scope/adapters/trae.d.ts +0 -2
- package/dist/src/services/skill-scope/adapters/trae.js +0 -12
- package/dist/src/services/skill-scope/detect.d.ts +0 -75
- package/dist/src/services/skill-scope/detect.js +0 -480
- package/dist/src/services/skill-scope/registry.d.ts +0 -41
- package/dist/src/services/skill-scope/registry.js +0 -83
- package/dist/src/services/skill-scope/source-of-truth.d.ts +0 -44
- package/dist/src/services/skill-scope/source-of-truth.js +0 -118
- package/dist/src/services/skill-scope/types.d.ts +0 -176
- package/dist/src/services/skill-scope/types.js +0 -74
|
@@ -519,6 +519,27 @@ export function registerCoreAndArtifactCommands(program, io) {
|
|
|
519
519
|
process.exitCode = 1;
|
|
520
520
|
}
|
|
521
521
|
});
|
|
522
|
+
addJsonOption(memory
|
|
523
|
+
.command('search <query>')
|
|
524
|
+
.description('Fuzzy-search the memory index (deterministic, local, zero-token). Default --limit 6.')
|
|
525
|
+
.option('--kind <kind>', 'filter by memory kind (one of: project, rule, decision, reference, feedback, convention, module, lesson)')
|
|
526
|
+
.option('--limit <n>', 'maximum number of matches to return', (value) => Number(value))
|
|
527
|
+
.option('--project <path>', 'target project root (defaults to git root or cwd)')).action((query, options) => {
|
|
528
|
+
// Lazy import avoids a top-of-file import cycle (memory-commands.ts
|
|
529
|
+
// imports services that the rest of this file may also touch).
|
|
530
|
+
void import('./memory-commands.js').then(({ runMemorySearch }) => {
|
|
531
|
+
runMemorySearch(io, {
|
|
532
|
+
query,
|
|
533
|
+
...(options.kind !== undefined ? { kind: options.kind } : {}),
|
|
534
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
535
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
536
|
+
...(options.json !== undefined ? { json: options.json } : {}),
|
|
537
|
+
});
|
|
538
|
+
}).catch((error) => {
|
|
539
|
+
printResult(io, fail('memory.search', 'MEMORY_SEARCH_BOOTSTRAP_FAILED', getErrorMessage(error), {}, []), options.json);
|
|
540
|
+
process.exitCode = 1;
|
|
541
|
+
});
|
|
542
|
+
});
|
|
522
543
|
const proxy = program.command('proxy').description('Manage proxy settings');
|
|
523
544
|
addJsonOption(proxy
|
|
524
545
|
.command('test')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
2
|
+
export interface MemorySearchCommandOptions {
|
|
3
|
+
query: string;
|
|
4
|
+
kind?: string;
|
|
5
|
+
limit?: number;
|
|
6
|
+
project?: string;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Run the memory search subcommand. Extracted so unit tests can
|
|
11
|
+
* exercise the full envelope without spawning a subprocess.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runMemorySearch(io: ProgramIO, options: MemorySearchCommandOptions): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
2
|
+
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
3
|
+
import { searchMemory } from '../../services/memory/memory-search-service.js';
|
|
4
|
+
import { fail, ok } from '../../shared/result.js';
|
|
5
|
+
import { getErrorMessage, printResult } from '../cli-helpers.js';
|
|
6
|
+
const VALID_KINDS = [
|
|
7
|
+
'project',
|
|
8
|
+
'rule',
|
|
9
|
+
'decision',
|
|
10
|
+
'reference',
|
|
11
|
+
'feedback',
|
|
12
|
+
'convention',
|
|
13
|
+
'module',
|
|
14
|
+
'lesson',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Run the memory search subcommand. Extracted so unit tests can
|
|
18
|
+
* exercise the full envelope without spawning a subprocess.
|
|
19
|
+
*/
|
|
20
|
+
export function runMemorySearch(io, options) {
|
|
21
|
+
const projectRoot = options.project !== undefined
|
|
22
|
+
? resolveCanonicalProjectRoot(options.project)
|
|
23
|
+
: (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
24
|
+
const kindFilter = options.kind !== undefined && VALID_KINDS.includes(options.kind)
|
|
25
|
+
? options.kind
|
|
26
|
+
: undefined;
|
|
27
|
+
// When the user passes --kind but the value isn't in the valid set,
|
|
28
|
+
// we silently pass `undefined` so the search returns the full set;
|
|
29
|
+
// that's friendlier than a hard error and matches the spec's
|
|
30
|
+
// "invalid kind -> empty matches" semantic for the filter path.
|
|
31
|
+
// (For the loader unit test we exercise the explicit-invalid path
|
|
32
|
+
// directly; here the CLI side is forgiving.)
|
|
33
|
+
try {
|
|
34
|
+
const matches = searchMemory({
|
|
35
|
+
query: options.query,
|
|
36
|
+
projectRoot,
|
|
37
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
38
|
+
...(kindFilter !== undefined ? { kind: kindFilter } : {}),
|
|
39
|
+
});
|
|
40
|
+
printResult(io, ok('memory.search', {
|
|
41
|
+
query: options.query,
|
|
42
|
+
total: matches.length,
|
|
43
|
+
matches,
|
|
44
|
+
warnings: [],
|
|
45
|
+
}, []), options.json);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const message = getErrorMessage(error);
|
|
49
|
+
const code = error.code ?? 'MEMORY_SEARCH_FAILED';
|
|
50
|
+
const suggestions = [];
|
|
51
|
+
if (code === 'INDEX_MISSING') {
|
|
52
|
+
suggestions.push('Run `peaks memory extract --apply` to build the index from memory/*.md files');
|
|
53
|
+
}
|
|
54
|
+
if (code === 'EMPTY_QUERY') {
|
|
55
|
+
suggestions.push('Use `peaks memory index` to list all entries');
|
|
56
|
+
}
|
|
57
|
+
printResult(io, fail('memory.search', code, message, { projectRoot, query: options.query }, suggestions), options.json);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004 subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup helper for projects upgraded from 1.4.1 → 1.4.2. Moves per-session
|
|
5
|
+
* files from the legacy `.peaks/<sid>/<role>/<file>.md` path into the canonical
|
|
6
|
+
* `.peaks/_runtime/<sid>/<role>/<file>.md` path. Default is dry-run; pass
|
|
7
|
+
* `--apply` to actually `rename` the files and remove emptied legacy dirs.
|
|
8
|
+
*/
|
|
9
|
+
import type { Command } from 'commander';
|
|
10
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
11
|
+
export declare function registerMigrate1_4_1Command(workspace: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004 subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup helper for projects upgraded from 1.4.1 → 1.4.2. Moves per-session
|
|
5
|
+
* files from the legacy `.peaks/<sid>/<role>/<file>.md` path into the canonical
|
|
6
|
+
* `.peaks/_runtime/<sid>/<role>/<file>.md` path. Default is dry-run; pass
|
|
7
|
+
* `--apply` to actually `rename` the files and remove emptied legacy dirs.
|
|
8
|
+
*/
|
|
9
|
+
import { ok, fail } from '../../shared/result.js';
|
|
10
|
+
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
11
|
+
import { addJsonOption } from '../cli-helpers.js';
|
|
12
|
+
import { planMigrate1_4_1, applyMigrate1_4_1 } from '../../services/workspace/migrate-1-4-1-service.js';
|
|
13
|
+
export function registerMigrate1_4_1Command(workspace, io) {
|
|
14
|
+
addJsonOption(workspace
|
|
15
|
+
.command('migrate-1-4-1')
|
|
16
|
+
.description('R004: Move per-session files from the legacy `.peaks/<sid>/<role>/<file>.md` path into the canonical `.peaks/_runtime/<sid>/<role>/<file>.md` path. ' +
|
|
17
|
+
'Default: dry-run. Pass --apply to actually `rename` the files and remove emptied legacy dirs. ' +
|
|
18
|
+
'Reads each file once, computes sha256, compares to canonical. Identical-content duplicates are removed from legacy. Content-mismatch files are reported and NOT deleted (manual review).')
|
|
19
|
+
.requiredOption('--project <path>', 'target project root')
|
|
20
|
+
.option('--apply', 'actually rename the files and remove empty legacy dirs (destructive); without it, dry-run only', false)).action(async (options) => {
|
|
21
|
+
try {
|
|
22
|
+
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
23
|
+
const apply = options.apply === true;
|
|
24
|
+
const result = apply ? applyMigrate1_4_1(projectRoot) : planMigrate1_4_1(projectRoot);
|
|
25
|
+
const envelope = ok('workspace.migrate-1-4-1', result);
|
|
26
|
+
io.stdout(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const envelope = fail('workspace.migrate-1-4-1', 'MIGRATE_FAILED', err.message, null, ['Run with --apply to attempt the move (default is dry-run only)']);
|
|
30
|
+
io.stdout(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type ProgramIO } from '../cli-helpers.js';
|
|
3
|
+
export interface RetrospectiveSearchCommandOptions {
|
|
4
|
+
query: string;
|
|
5
|
+
type?: string;
|
|
6
|
+
outcome?: string;
|
|
7
|
+
limit?: number;
|
|
8
|
+
project?: string;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function runRetrospectiveSearch(io: ProgramIO, options: RetrospectiveSearchCommandOptions): void;
|
|
3
12
|
export declare function registerRetrospectiveCommands(program: Command, io: ProgramIO): void;
|
|
@@ -3,8 +3,50 @@ import { resolveCanonicalProjectRoot } from '../../services/config/config-servic
|
|
|
3
3
|
import { loadRetrospectiveIndex } from '../../services/retrospective/retrospective-index.js';
|
|
4
4
|
import { showRetrospective } from '../../services/retrospective/retrospective-show.js';
|
|
5
5
|
import { migrateRetrospectiveFromMd } from '../../services/retrospective/migrate-from-md.js';
|
|
6
|
+
import { searchRetrospective } from '../../services/retrospective/retrospective-search-service.js';
|
|
6
7
|
import { fail, ok } from '../../shared/result.js';
|
|
7
8
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
9
|
+
const VALID_RETRO_TYPES = ['refactor', 'feature', 'bugfix', 'config', 'docs', 'chore'];
|
|
10
|
+
const VALID_RETRO_OUTCOMES = ['shipped', 'blocked', 'in-flight', 'cancelled'];
|
|
11
|
+
export function runRetrospectiveSearch(io, options) {
|
|
12
|
+
const projectRoot = options.project !== undefined
|
|
13
|
+
? resolveCanonicalProjectRoot(options.project)
|
|
14
|
+
: (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
15
|
+
const typeFilter = options.type !== undefined && VALID_RETRO_TYPES.includes(options.type)
|
|
16
|
+
? options.type
|
|
17
|
+
: undefined;
|
|
18
|
+
const outcomeFilter = options.outcome !== undefined && VALID_RETRO_OUTCOMES.includes(options.outcome)
|
|
19
|
+
? options.outcome
|
|
20
|
+
: undefined;
|
|
21
|
+
try {
|
|
22
|
+
const matches = searchRetrospective({
|
|
23
|
+
query: options.query,
|
|
24
|
+
projectRoot,
|
|
25
|
+
...(typeFilter !== undefined ? { type: typeFilter } : {}),
|
|
26
|
+
...(outcomeFilter !== undefined ? { outcome: outcomeFilter } : {}),
|
|
27
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
28
|
+
});
|
|
29
|
+
printResult(io, ok('retrospective.search', {
|
|
30
|
+
query: options.query,
|
|
31
|
+
total: matches.length,
|
|
32
|
+
matches,
|
|
33
|
+
warnings: [],
|
|
34
|
+
}, []), options.json);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const message = getErrorMessage(error);
|
|
38
|
+
const code = error.code ?? 'RETROSPECTIVE_SEARCH_FAILED';
|
|
39
|
+
const suggestions = [];
|
|
40
|
+
if (code === 'INDEX_MISSING') {
|
|
41
|
+
suggestions.push('Run `peaks retrospective migrate --apply` to build the index from legacy MDs');
|
|
42
|
+
}
|
|
43
|
+
if (code === 'EMPTY_QUERY') {
|
|
44
|
+
suggestions.push('Use `peaks retrospective index` to list all entries');
|
|
45
|
+
}
|
|
46
|
+
printResult(io, fail('retrospective.search', code, message, { projectRoot, query: options.query }, suggestions), options.json);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
8
50
|
export function registerRetrospectiveCommands(program, io) {
|
|
9
51
|
const retrospective = program.command('retrospective').description('Read the peaks retrospective index (R3: index.json, not the legacy <id>/ MD tree)');
|
|
10
52
|
addJsonOption(retrospective
|
|
@@ -110,4 +152,20 @@ export function registerRetrospectiveCommands(program, io) {
|
|
|
110
152
|
process.exitCode = 1;
|
|
111
153
|
}
|
|
112
154
|
});
|
|
155
|
+
addJsonOption(retrospective
|
|
156
|
+
.command('search <query>')
|
|
157
|
+
.description('Fuzzy-search the retrospective index (deterministic, local, zero-token). Default --limit 6.')
|
|
158
|
+
.option('--type <type>', `filter by retrospective type (one of: ${VALID_RETRO_TYPES.join(', ')})`)
|
|
159
|
+
.option('--outcome <outcome>', `filter by retrospective outcome (one of: ${VALID_RETRO_OUTCOMES.join(', ')})`)
|
|
160
|
+
.option('--limit <n>', 'maximum number of matches to return', (value) => Number(value))
|
|
161
|
+
.option('--project <path>', 'target project root (defaults to git root or cwd)')).action((query, options) => {
|
|
162
|
+
runRetrospectiveSearch(io, {
|
|
163
|
+
query,
|
|
164
|
+
...(options.type !== undefined ? { type: options.type } : {}),
|
|
165
|
+
...(options.outcome !== undefined ? { outcome: options.outcome } : {}),
|
|
166
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
167
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
168
|
+
...(options.json !== undefined ? { json: options.json } : {}),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
113
171
|
}
|
|
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
|
|
|
4
4
|
import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
|
|
5
5
|
import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
|
|
6
6
|
import { migrateWorkspace } from '../../services/workspace/migrate-service.js';
|
|
7
|
+
import { registerMigrate1_4_1Command } from './migrate-1-4-1-command.js';
|
|
7
8
|
import { ensureSessionWithRotation } from '../../services/session/session-manager.js';
|
|
8
9
|
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
9
10
|
import { applyHookInstall, readHookStatus } from '../../services/skills/hooks-settings-service.js';
|
|
@@ -398,6 +399,13 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
398
399
|
process.exitCode = 1;
|
|
399
400
|
}
|
|
400
401
|
});
|
|
402
|
+
// R004: subcommand to physically move per-session files from the legacy
|
|
403
|
+
// `.peaks/<sid>/<role>/<file>.md` path to the canonical
|
|
404
|
+
// `.peaks/_runtime/<sid>/<role>/<file>.md` path. The 2-tier fallback in
|
|
405
|
+
// artifact-prerequisites.ts accepts either location, so this command is
|
|
406
|
+
// purely a UX / filesystem-cleanup helper — the functional behavior is
|
|
407
|
+
// already correct without it.
|
|
408
|
+
registerMigrate1_4_1Command(workspace, io);
|
|
401
409
|
}
|
|
402
410
|
/**
|
|
403
411
|
* Resolve the first-time "install peaks hooks" decision for this project.
|
package/dist/src/cli/program.js
CHANGED
|
@@ -16,7 +16,6 @@ import { registerProjectCommands } from './commands/project-commands.js';
|
|
|
16
16
|
import { registerRequestCommands } from './commands/request-commands.js';
|
|
17
17
|
import { registerRetrospectiveCommands } from './commands/retrospective-commands.js';
|
|
18
18
|
import { registerScanCommands } from './commands/scan-commands.js';
|
|
19
|
-
import { registerShadcnCommands } from './commands/shadcn-commands.js';
|
|
20
19
|
import { registerSliceCommands } from './commands/slice-commands.js';
|
|
21
20
|
import { registerSopCommands } from './commands/sop-commands.js';
|
|
22
21
|
import { registerSubAgentCommands } from './commands/sub-agent-commands.js';
|
|
@@ -27,7 +26,6 @@ import { registerHooksCommands } from './commands/hooks-commands.js';
|
|
|
27
26
|
import { registerStatusLineCommands } from './commands/statusline-commands.js';
|
|
28
27
|
import { registerUnderstandCommands } from './commands/understand-commands.js';
|
|
29
28
|
import { registerWorkspaceCommands } from './commands/workspace-commands.js';
|
|
30
|
-
import { registerSkillScopeCommands } from './commands/skill-scope-commands.js';
|
|
31
29
|
import { registerWorkflowPlanCommands } from './commands/workflow-plan-commands.js';
|
|
32
30
|
export { printResult } from './cli-helpers.js';
|
|
33
31
|
export function createProgram(io = { stdout: (text) => console.log(text), stderr: (text) => console.error(text) }) {
|
|
@@ -37,13 +35,13 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
37
35
|
.description(`Peaks CLI ${CLI_VERSION} — workflow-gating CLI + skill family for Claude Code
|
|
38
36
|
|
|
39
37
|
Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
peaks doctor check your environment
|
|
39
|
+
peaks skill list or manage skills
|
|
40
|
+
peaks slice boundary check (tsc + vitest +3-way + verify-pipeline)
|
|
41
|
+
peaks workflow plan workflow routing dry-run graphs
|
|
42
|
+
peaks sop author your own workflow gates
|
|
43
|
+
peaks hooks install the un-bypassable gate-enforcement hook
|
|
44
|
+
peaks gate enforce/bypass SOP gates on Bash commands`)
|
|
47
45
|
.configureOutput({
|
|
48
46
|
writeOut: (text) => io.stdout(text.trimEnd()),
|
|
49
47
|
writeErr: (text) => io.stderr(text.trimEnd())
|
|
@@ -69,19 +67,19 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
69
67
|
}
|
|
70
68
|
}
|
|
71
69
|
catch { /* disk read is best-effort; zero skills is still truthful */ }
|
|
72
|
-
io.stdout(`Peaks CLI ${CLI_VERSION}
|
|
70
|
+
io.stdout(`Peaks CLI ${CLI_VERSION} · ${skillCount} skills ready
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
Peaks is a workflow-gating CLI + skill family for Claude Code.
|
|
73
|
+
It turns "don't skip steps" into hard enforcement — gates that block
|
|
74
|
+
advancement in-conversation, un-bypassably.
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
Before diving into a project, two things worth doing now:
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
peaks doctor check your environment in one glance
|
|
79
|
+
peaks-sop <<< ask this skill to author your first SOP
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
Or jump straight in:
|
|
82
|
+
peaks sop init --id my-flow --apply && peaks hooks install
|
|
85
83
|
`);
|
|
86
84
|
})
|
|
87
85
|
.exitOverride();
|
|
@@ -95,7 +93,6 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
95
93
|
registerRequestCommands(program, io);
|
|
96
94
|
registerRetrospectiveCommands(program, io);
|
|
97
95
|
registerScanCommands(program, io);
|
|
98
|
-
registerShadcnCommands(program, io);
|
|
99
96
|
registerSliceCommands(program, io);
|
|
100
97
|
registerSopCommands(program, io);
|
|
101
98
|
registerSubAgentCommands(program, io);
|
|
@@ -109,9 +106,6 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
109
106
|
registerStatusLineCommands(program, io);
|
|
110
107
|
registerUnderstandCommands(program, io);
|
|
111
108
|
registerWorkspaceCommands(program, io);
|
|
112
|
-
// Slice 025: peaks skill scope — per-project multi-IDE skill scoping.
|
|
113
|
-
registerSkillScopeCommands(program, io);
|
|
114
|
-
// Slice 025: peaks workflow plan — security/perf plan/result split CLI.
|
|
115
109
|
registerWorkflowPlanCommands(program, io);
|
|
116
110
|
return program;
|
|
117
111
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FuzzyMatchOptions, FuzzyMatchResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* String-overload: when `items` is an array of strings, the searchable text
|
|
4
|
+
* is the string itself. No keyFn is required.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fuzzyMatch<T extends string>(query: string, items: T[], options?: FuzzyMatchOptions): FuzzyMatchResult<T>[];
|
|
7
|
+
/**
|
|
8
|
+
* Object-overload: caller provides a `keyFn` that extracts the searchable
|
|
9
|
+
* text from each item. The keyFn is invoked once per item per call; the
|
|
10
|
+
* caller is responsible for ensuring the result is stable (e.g., don't
|
|
11
|
+
* concatenate mutable fields).
|
|
12
|
+
*/
|
|
13
|
+
export declare function fuzzyMatchWithKey<T>(query: string, items: T[], options: FuzzyMatchOptions & {
|
|
14
|
+
keyFn: (item: T) => string;
|
|
15
|
+
}): FuzzyMatchResult<T>[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Fzf } from 'fzf';
|
|
2
|
+
/**
|
|
3
|
+
* Default limit for fuzzy-match. Aligned with the spec's "--limit default 6".
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_LIMIT = 6;
|
|
6
|
+
/**
|
|
7
|
+
* String-overload: when `items` is an array of strings, the searchable text
|
|
8
|
+
* is the string itself. No keyFn is required.
|
|
9
|
+
*/
|
|
10
|
+
export function fuzzyMatch(query, items, options = {}) {
|
|
11
|
+
return fuzzyMatchWithKey(query, items, { ...options, keyFn: (item) => item });
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Object-overload: caller provides a `keyFn` that extracts the searchable
|
|
15
|
+
* text from each item. The keyFn is invoked once per item per call; the
|
|
16
|
+
* caller is responsible for ensuring the result is stable (e.g., don't
|
|
17
|
+
* concatenate mutable fields).
|
|
18
|
+
*/
|
|
19
|
+
export function fuzzyMatchWithKey(query, items, options) {
|
|
20
|
+
const { keyFn } = options;
|
|
21
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
22
|
+
if (items.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
// Empty query: surface all items (capped at limit) with neutral score and
|
|
25
|
+
// empty positions. Useful for "list" or "preview" use cases where the
|
|
26
|
+
// caller wants a deterministic top-N without a query.
|
|
27
|
+
if (query === '') {
|
|
28
|
+
return items.slice(0, limit).map((item) => ({ item, score: 0, positions: [] }));
|
|
29
|
+
}
|
|
30
|
+
const fzf = new Fzf(items, {
|
|
31
|
+
selector: keyFn,
|
|
32
|
+
limit,
|
|
33
|
+
// Per spec: default is case-insensitive (NOT fzf's smart-case).
|
|
34
|
+
// The user explicitly opts into case-sensitive via caseSensitive:true.
|
|
35
|
+
casing: options.caseSensitive === true ? 'case-sensitive' : 'case-insensitive',
|
|
36
|
+
// normalize:true (default) strips diacritics; fzf returns more matches
|
|
37
|
+
// for non-ASCII text this way, which is what we want for
|
|
38
|
+
// bilingual (zh-CN + en) memory entries.
|
|
39
|
+
});
|
|
40
|
+
const raw = fzf.find(query);
|
|
41
|
+
if (raw.length === 0)
|
|
42
|
+
return [];
|
|
43
|
+
// fzf-for-js score is "higher = better". Normalize so the top of the
|
|
44
|
+
// current batch is exactly 1.0 and others are in [0, 1].
|
|
45
|
+
// When the top score is 0 (degenerate — exact-character-only query that
|
|
46
|
+
// still matched somehow), fall back to 1.0 to avoid divide-by-zero.
|
|
47
|
+
const topScore = raw[0]?.score ?? 1;
|
|
48
|
+
const denom = topScore > 0 ? topScore : 1;
|
|
49
|
+
return raw.slice(0, limit).map((entry) => {
|
|
50
|
+
const score = topScore > 0 ? Number((entry.score / denom).toFixed(4)) : 1;
|
|
51
|
+
// positions is a Set<number> in fzf-for-js; convert to a sorted array
|
|
52
|
+
// so the JSON envelope is stable and human-readable.
|
|
53
|
+
const positions = [...entry.positions].sort((a, b) => a - b);
|
|
54
|
+
return { item: entry.item, score, positions };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the generic fuzzy-match kernel.
|
|
3
|
+
*/
|
|
4
|
+
export interface FuzzyMatchOptions {
|
|
5
|
+
/** Maximum number of matches to return. Default 6. */
|
|
6
|
+
limit?: number;
|
|
7
|
+
/** When true, matching is case-sensitive. Default false (smart-case). */
|
|
8
|
+
caseSensitive?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A single fuzzy-match hit. `item` is the original entry; `score` is
|
|
12
|
+
* normalized to [0, 1] with the top of the current batch at 1.0;
|
|
13
|
+
* `positions` is the set of char indices in the searchable text that
|
|
14
|
+
* contributed to the match.
|
|
15
|
+
*/
|
|
16
|
+
export interface FuzzyMatchResult<T> {
|
|
17
|
+
item: T;
|
|
18
|
+
score: number;
|
|
19
|
+
positions: number[];
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type { ProjectMemoryKind } from './project-memory-service.js';
|
|
2
|
+
import type { ProjectMemoryKind } from './project-memory-service.js';
|
|
3
|
+
/**
|
|
4
|
+
* One entry in `.peaks/memory/index.json` after the on-disk `hot[]` +
|
|
5
|
+
* `cold[]` arrays are flattened. Mirrors the field shape that the
|
|
6
|
+
* existing `project-memory-service.ts` writer emits.
|
|
7
|
+
*/
|
|
8
|
+
export interface MemoryIndexEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
kind: ProjectMemoryKind;
|
|
11
|
+
description: string;
|
|
12
|
+
sourcePath: string;
|
|
13
|
+
sourceArtifact: string | null;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The full snapshot of `.peaks/memory/index.json`.
|
|
18
|
+
*/
|
|
19
|
+
export interface MemoryIndexSnapshot {
|
|
20
|
+
indexPath: string;
|
|
21
|
+
version: number;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
entries: MemoryIndexEntry[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Input to `searchMemory`. `projectRoot` defaults to the resolved
|
|
27
|
+
* peaks project root (CLI resolves this before calling). `query` is
|
|
28
|
+
* required and non-empty.
|
|
29
|
+
*/
|
|
30
|
+
export interface MemorySearchInput {
|
|
31
|
+
query: string;
|
|
32
|
+
projectRoot?: string;
|
|
33
|
+
limit?: number;
|
|
34
|
+
kind?: ProjectMemoryKind;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One hit returned by `searchMemory`. Mirrors the entry shape with a
|
|
38
|
+
* normalized score in [0, 1] (top of batch = 1.0) and the char indices
|
|
39
|
+
* in the searchable text that contributed to the match.
|
|
40
|
+
*/
|
|
41
|
+
export interface MemorySearchResult {
|
|
42
|
+
name: string;
|
|
43
|
+
kind: ProjectMemoryKind;
|
|
44
|
+
description: string;
|
|
45
|
+
sourcePath: string;
|
|
46
|
+
score: number;
|
|
47
|
+
positions: number[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read `.peaks/memory/index.json` and flatten the on-disk `hot[<kind>][]`
|
|
51
|
+
* + `cold[]` shape into a single `entries[]` array. Throws structured
|
|
52
|
+
* errors with stable `code` markers that the CLI converts to the
|
|
53
|
+
* peaks envelope.
|
|
54
|
+
*/
|
|
55
|
+
export declare function loadMemoryIndex(projectRoot: string): MemoryIndexSnapshot;
|
|
56
|
+
/**
|
|
57
|
+
* Run the generic fuzzy kernel against the on-disk memory index. The
|
|
58
|
+
* searchable text is `name + " " + description` for each entry (per
|
|
59
|
+
* spec §Component Details).
|
|
60
|
+
*/
|
|
61
|
+
export declare function searchMemory(input: MemorySearchInput): MemorySearchResult[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fuzzyMatchWithKey } from '../fuzzy-matching/fuzzy-match-service.js';
|
|
4
|
+
const DEFAULT_LIMIT = 6;
|
|
5
|
+
/**
|
|
6
|
+
* Read `.peaks/memory/index.json` and flatten the on-disk `hot[<kind>][]`
|
|
7
|
+
* + `cold[]` shape into a single `entries[]` array. Throws structured
|
|
8
|
+
* errors with stable `code` markers that the CLI converts to the
|
|
9
|
+
* peaks envelope.
|
|
10
|
+
*/
|
|
11
|
+
export function loadMemoryIndex(projectRoot) {
|
|
12
|
+
const indexPath = join(projectRoot, '.peaks', 'memory', 'index.json');
|
|
13
|
+
if (!existsSync(indexPath)) {
|
|
14
|
+
const err = new Error(`INDEX_MISSING: memory index not found at ${indexPath}`);
|
|
15
|
+
err.code = 'INDEX_MISSING';
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = readFileSync(indexPath, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
const err = new Error(`INDEX_INVALID: failed to read memory index at ${indexPath}: ${cause.message}`);
|
|
24
|
+
err.code = 'INDEX_INVALID';
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch (cause) {
|
|
32
|
+
const err = new Error(`INDEX_INVALID: malformed memory index at ${indexPath}: ${cause.message}`);
|
|
33
|
+
err.code = 'INDEX_INVALID';
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
const index = parsed;
|
|
37
|
+
const hot = index.hot ?? {};
|
|
38
|
+
const flatFromHot = Object.values(hot).flat();
|
|
39
|
+
const flatFromCold = (index.cold ?? []);
|
|
40
|
+
const entries = [...flatFromHot, ...flatFromCold];
|
|
41
|
+
return {
|
|
42
|
+
indexPath,
|
|
43
|
+
version: index.version ?? 1,
|
|
44
|
+
updatedAt: index.updatedAt ?? '',
|
|
45
|
+
entries,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run the generic fuzzy kernel against the on-disk memory index. The
|
|
50
|
+
* searchable text is `name + " " + description` for each entry (per
|
|
51
|
+
* spec §Component Details).
|
|
52
|
+
*/
|
|
53
|
+
export function searchMemory(input) {
|
|
54
|
+
if (input.query === '') {
|
|
55
|
+
const err = new Error('EMPTY_QUERY: searchMemory requires a non-empty query (use `peaks memory index` to list all)');
|
|
56
|
+
err.code = 'EMPTY_QUERY';
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
const projectRoot = input.projectRoot ?? process.cwd();
|
|
60
|
+
const limit = input.limit ?? DEFAULT_LIMIT;
|
|
61
|
+
const snapshot = loadMemoryIndex(projectRoot);
|
|
62
|
+
let candidates = snapshot.entries;
|
|
63
|
+
if (input.kind !== undefined) {
|
|
64
|
+
candidates = candidates.filter((e) => e.kind === input.kind);
|
|
65
|
+
}
|
|
66
|
+
// Per spec: searchable text is name + " " + description.
|
|
67
|
+
// The keyFn is invoked once per item per call.
|
|
68
|
+
const matches = fuzzyMatchWithKey(input.query, candidates, { keyFn: (e) => `${e.name} ${e.description}`, limit, caseSensitive: false });
|
|
69
|
+
return matches.map((m) => {
|
|
70
|
+
const entry = m.item;
|
|
71
|
+
return {
|
|
72
|
+
name: entry.name,
|
|
73
|
+
kind: entry.kind,
|
|
74
|
+
description: entry.description,
|
|
75
|
+
sourcePath: entry.sourcePath,
|
|
76
|
+
score: m.score,
|
|
77
|
+
positions: m.positions,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -95,7 +95,6 @@ export const seedCapabilityItems = [
|
|
|
95
95
|
capability('agent-browser.browser-agent', 'agent-browser', 'Agent Browser', 'agent', 'browser-agent', ['engineer', 'qa'], 'medium', 'manual-browser-walkthrough', 'Use screenshots and manual test steps if agent browser is unavailable.', 'Agent Browser', '浏览器代理', 'Supports browser-based validation and interaction planning.', '支持基于浏览器的验证和交互规划。'),
|
|
96
96
|
capability('minimax-skills.worker-guidance', 'minimax-skills', 'MiniMax Worker Guidance', 'skill', 'worker-guidance', ['engineer'], 'medium', 'peaks-worker-contract', 'Use Peaks built-in minimax-worker contract and review handoff.', 'MiniMax Worker Guidance', 'MiniMax Worker 指南', 'Guides MiniMax coding/test worker delegation.', '指导 MiniMax 编码/测试 worker 委托。'),
|
|
97
97
|
capability('claude-mem.memory-persistence', 'claude-mem', 'Claude Memory Persistence', 'skill', 'memory', ['engineer'], 'medium', 'peaks-txt-context-capsule', 'Use peaks-txt context capsules without storing secrets.', 'Memory Persistence', '记忆持久化', 'Persists reusable context when explicitly approved.', '在明确授权时持久化可复用上下文。'),
|
|
98
|
-
capability('shadcn-ui.component-system', 'shadcn-ui', 'shadcn/ui Component System', 'doc', 'ui-components', ['engineer', 'designer'], 'low', 'project-local-ui-patterns', 'Use existing project components and design tokens.', 'Component System Reference', '组件系统参考', 'Provides component and design-system references for UI planning.', '为 UI 规划提供组件和设计系统参考。'),
|
|
99
98
|
capability('openspec.spec-workflow', 'openspec', 'OpenSpec Workflow', 'workflow', 'spec-workflow', ['product', 'engineer'], 'low', 'peaks-prd-rd-qa-artifacts', 'Use Peaks built-in PRD/RD/QA artifact flow.', 'OpenSpec Workflow', 'OpenSpec 规格流程', 'Supports spec-first product and engineering governance.', '支持规格优先的产品与工程治理。'),
|
|
100
99
|
capability('gitnexus.repo-intelligence', 'gitnexus', 'GitNexus Repository Intelligence', 'cli', 'repo-intelligence', ['engineer'], 'medium', 'local-repo-scan', 'Use local project scanning through Peaks RD.', 'Repository Intelligence', '仓库智能分析', 'Repository intelligence should be proxied through Peaks before use.', '仓库智能分析应先通过 Peaks 代理边界再使用。'),
|
|
101
100
|
capability('claude-code-best-practice.workflow-guidance', 'claude-code-best-practice', 'Claude Code Best Practice', 'doc', 'workflow-guidance', ['engineer'], 'low', 'peaks-built-in-rules', 'Use Peaks built-in workflow and review rules.', 'Claude Code Best Practice', 'Claude Code 最佳实践', 'Guidance for Claude Code engineering workflows.', 'Claude Code 工程工作流指导。'),
|
|
@@ -33,7 +33,6 @@ export const seedCapabilityLandingMappings = [
|
|
|
33
33
|
mapping({ capabilityId: 'agent-browser.browser-agent', sourceId: 'agent-browser', sourceGroup: 'mcp-server', landingKind: 'skill', target: 'peaks-qa', skillName: 'peaks-qa', guidance: 'Use for browser validation; never submit forms or mutate authenticated state without explicit permission.' }),
|
|
34
34
|
mapping({ capabilityId: 'minimax-skills.worker-guidance', sourceId: 'minimax-skills', sourceGroup: 'mcp-server', landingKind: 'cli', target: 'peaks minimax-worker', commandPreview: 'peaks minimax-worker --json', guidance: 'Use Peaks worker command only after reviewing inputs; add --confirm manually when explicit external-provider approval exists.' }),
|
|
35
35
|
mapping({ capabilityId: 'claude-mem.memory-persistence', sourceId: 'claude-mem', sourceGroup: 'mcp-server', landingKind: 'skill', target: 'peaks-txt', skillName: 'peaks-txt', guidance: 'Use only with explicit durable-memory consent and never store secrets.' }),
|
|
36
|
-
mapping({ capabilityId: 'shadcn-ui.component-system', sourceId: 'shadcn-ui', sourceGroup: 'mcp-server', landingKind: 'skill', target: 'peaks-ui', skillName: 'peaks-ui', guidance: 'Use as a component-system reference, not as an unreviewed generated UI default.' }),
|
|
37
36
|
mapping({ capabilityId: 'darwin-skill.external-skill', sourceId: 'darwin-skill', sourceGroup: 'mcp-server', landingKind: 'catalog', target: 'external skill catalog', guidance: 'Catalog only until inspected for project fit and safety.' }),
|
|
38
37
|
mapping({ capabilityId: 'claude-code-best-practice.workflow-guidance', sourceId: 'claude-code-best-practice', sourceGroup: 'mcp-server', landingKind: 'skill', target: 'peaks-rd', skillName: 'peaks-rd', guidance: 'Use as Claude Code workflow reference while preserving Peaks gates.' }),
|
|
39
38
|
mapping({ capabilityId: 'openspec.spec-workflow', sourceId: 'openspec', sourceGroup: 'mcp-server', landingKind: 'skill', target: 'peaks-prd', skillName: 'peaks-prd', guidance: 'Use for spec-first product and engineering artifact structure.' }),
|
|
@@ -17,7 +17,6 @@ export const seedCapabilitySources = [
|
|
|
17
17
|
{ sourceId: 'agent-browser', sourceType: 'repo', sourceGroup: 'mcp-server', title: 'Agent Browser', url: 'https://github.com/vercel-labs/agent-browser', discoveryStatus: 'indexed', items: ['agent-browser.browser-agent'] },
|
|
18
18
|
{ sourceId: 'minimax-skills', sourceType: 'skills-package', sourceGroup: 'mcp-server', title: 'MiniMax Skills', url: 'https://github.com/MiniMax-AI/skills', discoveryStatus: 'indexed', items: ['minimax-skills.worker-guidance'] },
|
|
19
19
|
{ sourceId: 'claude-mem', sourceType: 'repo', sourceGroup: 'mcp-server', title: 'claude-mem', url: 'https://github.com/thedotmack/claude-mem', discoveryStatus: 'indexed', items: ['claude-mem.memory-persistence'] },
|
|
20
|
-
{ sourceId: 'shadcn-ui', sourceType: 'repo', sourceGroup: 'mcp-server', title: 'shadcn/ui', url: 'https://github.com/shadcn-ui/ui', discoveryStatus: 'indexed', items: ['shadcn-ui.component-system'] },
|
|
21
20
|
{ sourceId: 'darwin-skill', sourceType: 'skills-package', sourceGroup: 'mcp-server', title: 'darwin-skill', url: 'https://github.com/alchaincyf/darwin-skill', discoveryStatus: 'unscanned', items: ['darwin-skill.external-skill'] },
|
|
22
21
|
{ sourceId: 'claude-code-best-practice', sourceType: 'repo', sourceGroup: 'mcp-server', title: 'Claude Code Best Practice', url: 'https://github.com/shanraisshan/claude-code-best-practice', discoveryStatus: 'indexed', items: ['claude-code-best-practice.workflow-guidance'] },
|
|
23
22
|
{ sourceId: 'openspec', sourceType: 'repo', sourceGroup: 'mcp-server', title: 'OpenSpec', url: 'https://github.com/Fission-AI/OpenSpec', discoveryStatus: 'indexed', items: ['openspec.spec-workflow'] },
|