@llman-sdd/core 0.1.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.
- package/package.json +33 -0
- package/src/archive/freeze.ts +150 -0
- package/src/archive/sevenzip.ts +110 -0
- package/src/change/frontmatter.ts +49 -0
- package/src/change/id.ts +50 -0
- package/src/change/lifecycle.ts +141 -0
- package/src/config/load.ts +48 -0
- package/src/config/schema.ts +136 -0
- package/src/context/indexStore.ts +132 -0
- package/src/context/retrieve.ts +334 -0
- package/src/context/tree.ts +118 -0
- package/src/git/spawnGit.ts +89 -0
- package/src/index.ts +176 -0
- package/src/init/defaultConfig.ts +73 -0
- package/src/init/init.ts +145 -0
- package/src/ports.ts +20 -0
- package/src/report/collect.ts +164 -0
- package/src/report/graph.ts +103 -0
- package/src/report/show.ts +138 -0
- package/src/report/specHelpers.ts +63 -0
- package/src/report/specs.ts +79 -0
- package/src/review/review.ts +142 -0
- package/src/spec/ir.ts +43 -0
- package/src/spec/parser.ts +171 -0
- package/src/spec/reqRegistry.ts +38 -0
- package/src/templates/engine.ts +51 -0
- package/src/templates/locale.ts +24 -0
- package/src/templates/skills.ts +165 -0
- package/src/validation/discover.ts +30 -0
- package/src/validation/validate.ts +153 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llmanspec/config.yaml contract (config-schema capability): field-for-field
|
|
3
|
+
* alignment with v1 (crates/llman-sdd/src/sdd/project/config.rs). Unknown
|
|
4
|
+
* keys are tolerated (v1 schema sets no additionalProperties:false); field
|
|
5
|
+
* names stay snake_case for v1-config readability.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
export const EXTRA_SKILLS = [
|
|
10
|
+
'llman-sdd-continue',
|
|
11
|
+
'llman-sdd-ff',
|
|
12
|
+
'llman-sdd-validate',
|
|
13
|
+
'llman-sdd-arch-review',
|
|
14
|
+
'llman-sdd-wayfinder',
|
|
15
|
+
'llman-sdd-research',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export const tagsBindingSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
kind: z.literal('tags').describe('Tag predicate source kind.'),
|
|
21
|
+
tags: z.array(z.string()).min(1).describe('Required tag names (leading `@` optional).'),
|
|
22
|
+
})
|
|
23
|
+
.describe('A scenario is bound when its tags contain ALL listed tags.');
|
|
24
|
+
|
|
25
|
+
export const scenarioAttrsBindingSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
kind: z.literal('scenario-attrs').describe('Per-scenario attribute source kind.'),
|
|
28
|
+
files: z
|
|
29
|
+
.array(z.string())
|
|
30
|
+
.min(1)
|
|
31
|
+
.describe('Glob patterns of source files to scan for scenario attribute blocks.'),
|
|
32
|
+
})
|
|
33
|
+
.describe('Extract path/name literal pairs from scenario attribute blocks in matched files.');
|
|
34
|
+
|
|
35
|
+
export const bindingSchema = z.discriminatedUnion('kind', [
|
|
36
|
+
tagsBindingSchema,
|
|
37
|
+
scenarioAttrsBindingSchema,
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export const bddSchema = z.object({
|
|
41
|
+
framework: z
|
|
42
|
+
.string()
|
|
43
|
+
.default('')
|
|
44
|
+
.describe(
|
|
45
|
+
'BDD framework identifier (optional). Only used to derive a default run_command when run_command is unset.',
|
|
46
|
+
),
|
|
47
|
+
feature_dir: z
|
|
48
|
+
.string()
|
|
49
|
+
.nullish()
|
|
50
|
+
.describe('Root directory for .feature files, relative to project root.'),
|
|
51
|
+
default_language: z
|
|
52
|
+
.string()
|
|
53
|
+
.nullish()
|
|
54
|
+
.describe("Gherkin parsing language code (e.g. 'en', 'zh-CN'). Default: 'en'."),
|
|
55
|
+
run_command: z
|
|
56
|
+
.string()
|
|
57
|
+
.nullish()
|
|
58
|
+
.describe(
|
|
59
|
+
'Custom test run command. Placeholders: {feature_dir}, {feature_name}, {feature_path}. Without placeholders, validate --all/--specs runs the command at most once per batch (batch-once).',
|
|
60
|
+
),
|
|
61
|
+
verify_prompt: z.string().nullish().describe('Extra prompt text injected during verify phase.'),
|
|
62
|
+
bindings: z.array(bindingSchema).nullish().describe('Declared harness-binding sources.'),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const archiveSchema = z.object({
|
|
66
|
+
strict_defer: z
|
|
67
|
+
.boolean()
|
|
68
|
+
.nullish()
|
|
69
|
+
.describe(
|
|
70
|
+
'When true, unchecked tasks without a defer link are errors (not just warnings). Default: false.',
|
|
71
|
+
),
|
|
72
|
+
min_completion_ratio: z
|
|
73
|
+
.number()
|
|
74
|
+
.nullish()
|
|
75
|
+
.describe(
|
|
76
|
+
'Minimum task completion ratio (0.0-1.0) required for archiving. Default: none (disabled).',
|
|
77
|
+
),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const sddSchema = z.object({
|
|
81
|
+
branch_prefix: z
|
|
82
|
+
.string()
|
|
83
|
+
.nullish()
|
|
84
|
+
.describe('Prefix for feature branches created by `change start`. Default: "sdd/".'),
|
|
85
|
+
worktree_root: z
|
|
86
|
+
.string()
|
|
87
|
+
.nullish()
|
|
88
|
+
.describe('Root directory for worktrees created by `change start --worktree`.'),
|
|
89
|
+
worktree_naming: z
|
|
90
|
+
.enum(['id', 'hash'])
|
|
91
|
+
.nullish()
|
|
92
|
+
.describe(
|
|
93
|
+
'Worktree directory naming: "id" (default) or "hash" (base32(sha256(change_id))[:8]).',
|
|
94
|
+
),
|
|
95
|
+
merge_method: z
|
|
96
|
+
.enum(['squash', 'ff'])
|
|
97
|
+
.nullish()
|
|
98
|
+
.describe('Default merge method for finalize/archive close-out. "squash" (default) or "ff".'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const changeIdSchema = z.object({
|
|
102
|
+
pattern: z
|
|
103
|
+
.string()
|
|
104
|
+
.nullish()
|
|
105
|
+
.describe('Regex active change ids MUST fully match (anchored full-match).'),
|
|
106
|
+
template: z
|
|
107
|
+
.string()
|
|
108
|
+
.nullish()
|
|
109
|
+
.describe('Template rendered by `change new --from` with preset vars.'),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const sddConfigSchema = z.object({
|
|
113
|
+
schema: z.literal('spec-driven').describe('Schema identifier. Must be "spec-driven".'),
|
|
114
|
+
locale: z.string().default('en').describe('Locale used for SDD templates and skills.'),
|
|
115
|
+
extra_skills: z
|
|
116
|
+
.array(z.enum(EXTRA_SKILLS))
|
|
117
|
+
.nullish()
|
|
118
|
+
.describe('Additional optional SDD skills to enable (extend candidates on init --update).'),
|
|
119
|
+
archive: archiveSchema
|
|
120
|
+
.nullish()
|
|
121
|
+
.describe('Archive behaviour settings (defer tracking, completion gates).'),
|
|
122
|
+
bdd: bddSchema
|
|
123
|
+
.nullish()
|
|
124
|
+
.describe(
|
|
125
|
+
'BDD integration settings. When defined, enables feature-as-spec mode and BDD-aware verify prompts.',
|
|
126
|
+
),
|
|
127
|
+
sdd: sddSchema
|
|
128
|
+
.nullish()
|
|
129
|
+
.describe('Unified Git-native flow tuning (branch prefix, worktree, merge method).'),
|
|
130
|
+
change_id: changeIdSchema
|
|
131
|
+
.nullish()
|
|
132
|
+
.describe('Optional machine-readable change id naming convention.'),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export type SddConfig = z.output<typeof sddConfigSchema>;
|
|
136
|
+
export type SddConfigInput = z.input<typeof sddConfigSchema>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index orchestration (context-index capability, r26): rebuild writes the
|
|
3
|
+
* pageindex tree under a create-new lock; check compares the stored spec_hash
|
|
4
|
+
* against a fresh one.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import type { SpecEntry } from '../validation/validate.ts';
|
|
9
|
+
import { buildTreeIndex, computeSpecHash, type HashIo, type SerializedTreeIndex } from './tree.ts';
|
|
10
|
+
|
|
11
|
+
export const CONTEXT_DIR_REL = 'llmanspec/.context';
|
|
12
|
+
export const PAGEINDEX_DIR_REL = `${CONTEXT_DIR_REL}/pageindex`;
|
|
13
|
+
export const TREE_JSON_REL = `${PAGEINDEX_DIR_REL}/tree.json`;
|
|
14
|
+
export const REBUILD_LOCK_REL = `${PAGEINDEX_DIR_REL}/.rebuild.lock`;
|
|
15
|
+
const LOCK_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export interface IndexIo {
|
|
18
|
+
exists(path: string): boolean;
|
|
19
|
+
readText(path: string): string;
|
|
20
|
+
writeText(path: string, content: string): void;
|
|
21
|
+
remove(path: string): void;
|
|
22
|
+
isDirectory(path: string): boolean;
|
|
23
|
+
listDir(path: string): string[];
|
|
24
|
+
mkdirp(path: string): void;
|
|
25
|
+
processAlive(pid: number): boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RebuildOpts {
|
|
29
|
+
chatModel: string;
|
|
30
|
+
buildTimestamp?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RebuildResult {
|
|
34
|
+
lines: string[];
|
|
35
|
+
specCount: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse the minimal TOML-ish lock (pid/started_at/...). */
|
|
39
|
+
export function parseLock(content: string): { pid: number; startedAt: string } | null {
|
|
40
|
+
const pid = content.match(/^pid\s*=\s*(\d+)/mu)?.[1];
|
|
41
|
+
const startedAt = content.match(/^started_at\s*=\s*"([^"]+)"/mu)?.[1];
|
|
42
|
+
if (pid === undefined || startedAt === undefined) return null;
|
|
43
|
+
return { pid: Number(pid), startedAt };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function acquireLock(io: IndexIo, lockPath: string): void {
|
|
47
|
+
if (io.exists(lockPath)) {
|
|
48
|
+
const parsed = parseLock(io.readText(lockPath));
|
|
49
|
+
const stale =
|
|
50
|
+
parsed === null ||
|
|
51
|
+
Date.now() - Date.parse(parsed.startedAt) > LOCK_MAX_AGE_MS ||
|
|
52
|
+
!io.processAlive(parsed.pid);
|
|
53
|
+
if (!stale) throw new Error(`rebuild already in progress (lock: ${lockPath})`);
|
|
54
|
+
io.remove(lockPath);
|
|
55
|
+
}
|
|
56
|
+
io.writeText(
|
|
57
|
+
lockPath,
|
|
58
|
+
`pid = ${process.pid}\nstarted_at = "${new Date().toISOString()}"\nchunks_total = 1\nchunks_done = 1\nprogress_pct = 100\n`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function rebuildIndex(
|
|
63
|
+
io: IndexIo,
|
|
64
|
+
root: string,
|
|
65
|
+
specsDir: string,
|
|
66
|
+
entries: readonly SpecEntry[],
|
|
67
|
+
opts: RebuildOpts,
|
|
68
|
+
): RebuildResult {
|
|
69
|
+
const lockRel = REBUILD_LOCK_REL;
|
|
70
|
+
acquireLock(io, lockRel);
|
|
71
|
+
try {
|
|
72
|
+
const specHash = computeSpecHash(specsDir, io);
|
|
73
|
+
const tree = buildTreeIndex(entries, {
|
|
74
|
+
specHash,
|
|
75
|
+
buildTimestamp: opts.buildTimestamp ?? new Date().toISOString(),
|
|
76
|
+
chatModel: opts.chatModel,
|
|
77
|
+
});
|
|
78
|
+
io.mkdirp(PAGEINDEX_DIR_REL);
|
|
79
|
+
io.writeText(TREE_JSON_REL, `${JSON.stringify(tree, null, 2)}\n`);
|
|
80
|
+
return {
|
|
81
|
+
lines: [
|
|
82
|
+
'Scanning specs for pageindex tree (no LLM)...',
|
|
83
|
+
`Building tree from ${entries.length} specs...`,
|
|
84
|
+
`pageindex tree index rebuilt (${entries.length} specs, chat_model=${opts.chatModel === '' ? '<unset>' : opts.chatModel})`,
|
|
85
|
+
],
|
|
86
|
+
specCount: entries.length,
|
|
87
|
+
};
|
|
88
|
+
} finally {
|
|
89
|
+
io.remove(lockRel);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface FreshnessResult {
|
|
94
|
+
fresh: boolean;
|
|
95
|
+
lines: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function checkIndexFreshness(io: IndexIo, root: string, specsDir: string): FreshnessResult {
|
|
99
|
+
if (!io.exists(TREE_JSON_REL)) {
|
|
100
|
+
return { fresh: false, lines: ['[pageindex] missing — run `index rebuild` first'] };
|
|
101
|
+
}
|
|
102
|
+
let tree: SerializedTreeIndex;
|
|
103
|
+
try {
|
|
104
|
+
tree = JSON.parse(io.readText(TREE_JSON_REL)) as SerializedTreeIndex;
|
|
105
|
+
} catch {
|
|
106
|
+
return { fresh: false, lines: ['[pageindex] stale (tree.json corrupted)'] };
|
|
107
|
+
}
|
|
108
|
+
const current = computeSpecHash(specsDir, io);
|
|
109
|
+
const model = tree.chat_model === '' ? '<unset>' : tree.chat_model;
|
|
110
|
+
if (tree.spec_hash === current) {
|
|
111
|
+
return {
|
|
112
|
+
fresh: true,
|
|
113
|
+
lines: [
|
|
114
|
+
`[pageindex] fresh (built ${tree.build_timestamp}, ${tree.docs.length} specs, chat model: ${model})`,
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { fresh: false, lines: ['[pageindex] stale — specs changed since last rebuild'] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Load the tree for retrieval (absent/corrupt → null). */
|
|
122
|
+
export function loadTree(io: IndexIo, root: string): SerializedTreeIndex | null {
|
|
123
|
+
const path = join(root, TREE_JSON_REL);
|
|
124
|
+
if (!io.exists(path)) return null;
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(io.readText(path)) as SerializedTreeIndex;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type { HashIo };
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context agentic retrieval (context-index capability, r27-r29):
|
|
3
|
+
* OpenAI-compatible /chat/completions with tool calling — three local tools,
|
|
4
|
+
* 12-round cap, then one forced no-tools turn. r28 dedups the model's
|
|
5
|
+
* direct/related classification (direct wins). r29 pins the output contract:
|
|
6
|
+
* quality ∈ {agentic, unavailable}, exhaustion degrades to agentic +
|
|
7
|
+
* truncation note, failures emit summary {totalSpecs:0, error:true}.
|
|
8
|
+
* Env contract mirrors v1: LLMAN_SDD_INDEX_CHAT_MODEL (required) /
|
|
9
|
+
* LLMAN_SDD_INDEX_CHAT_API_HOST|KEY with LLMAN_SDD_INDEX_OPENAI_* fallbacks.
|
|
10
|
+
*/
|
|
11
|
+
import type { SerializedTreeIndex } from './tree.ts';
|
|
12
|
+
|
|
13
|
+
export const MAX_TOOL_ROUNDS = 12;
|
|
14
|
+
|
|
15
|
+
export interface ChatConfig {
|
|
16
|
+
model: string;
|
|
17
|
+
host: string;
|
|
18
|
+
apiKey: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const envOr = (env: Record<string, string | undefined>, key: string): string | null =>
|
|
22
|
+
env[key]?.trim() || null;
|
|
23
|
+
|
|
24
|
+
export function resolveChatConfig(env: Record<string, string | undefined>): ChatConfig | null {
|
|
25
|
+
const model = envOr(env, 'LLMAN_SDD_INDEX_CHAT_MODEL');
|
|
26
|
+
if (model === null) return null;
|
|
27
|
+
const host = (
|
|
28
|
+
envOr(env, 'LLMAN_SDD_INDEX_CHAT_API_HOST') ??
|
|
29
|
+
envOr(env, 'LLMAN_SDD_INDEX_OPENAI_API_HOST') ??
|
|
30
|
+
'https://api.openai.com/v1'
|
|
31
|
+
).replace(/\/$/u, '');
|
|
32
|
+
const apiKey =
|
|
33
|
+
envOr(env, 'LLMAN_SDD_INDEX_CHAT_API_KEY') ??
|
|
34
|
+
envOr(env, 'LLMAN_SDD_INDEX_OPENAI_API_KEY') ??
|
|
35
|
+
'';
|
|
36
|
+
return { model, host, apiKey };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TierEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ContextSuccessSummary {
|
|
45
|
+
totalSpecs: number;
|
|
46
|
+
tierDirect: number;
|
|
47
|
+
tierRelated: number;
|
|
48
|
+
unrelatedCount: number;
|
|
49
|
+
toolCalls: number;
|
|
50
|
+
staleWarnings: string[];
|
|
51
|
+
readRecommended: string[];
|
|
52
|
+
paths: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** r29: failures collapse to v1's print_err two-field error summary. */
|
|
56
|
+
export interface ContextErrorSummary {
|
|
57
|
+
totalSpecs: 0;
|
|
58
|
+
error: true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ContextSummary = ContextSuccessSummary | ContextErrorSummary;
|
|
62
|
+
|
|
63
|
+
export interface ContextResult {
|
|
64
|
+
status: {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
/** r29: value domain is `agentic` (incl. degraded truncation) | `unavailable`. */
|
|
67
|
+
quality: 'agentic' | 'unavailable';
|
|
68
|
+
qualityNote: string;
|
|
69
|
+
errorKind?: string;
|
|
70
|
+
};
|
|
71
|
+
direct: TierEntry[];
|
|
72
|
+
related: TierEntry[];
|
|
73
|
+
summary: ContextSummary;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SYSTEM_PROMPT = `You are a spec retriever. Given a task, classify which capability specs MUST be read.
|
|
77
|
+
1. Call list_specs() to see all available spec documents and their purposes.
|
|
78
|
+
2. For specs whose purpose seems relevant to the task, call get_document_structure(spec_id) to see requirements.
|
|
79
|
+
3. For requirements that look relevant, call get_spec_content(spec_id, req_ids) to read the requirement text.
|
|
80
|
+
Then output ONLY this JSON (no markdown fence):
|
|
81
|
+
{\\"direct\\": [{\\"id\\": \\"<spec_id>\\", \\"reason\\": \\"<one sentence why this MUST be read>\\"}], \\"related\\": [{\\"id\\": \\"<spec_id>\\", \\"reason\\": \\"<one sentence>\\"}]}
|
|
82
|
+
- "direct" = specs whose behavior contract (any MUST/SHALL statement, command behavior) the task changes or depends on.
|
|
83
|
+
- "related" = specs that provide useful context but whose contract won't change.
|
|
84
|
+
- If the task changes behavior, the governing spec MUST be in "direct".
|
|
85
|
+
- Decide based on the requirement text you read, not on the spec id alone.`;
|
|
86
|
+
|
|
87
|
+
const TOOL_SCHEMAS = [
|
|
88
|
+
{
|
|
89
|
+
type: 'function',
|
|
90
|
+
function: {
|
|
91
|
+
name: 'list_specs',
|
|
92
|
+
description: 'List all spec documents with their purposes.',
|
|
93
|
+
parameters: { type: 'object', properties: {} },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: 'function',
|
|
98
|
+
function: {
|
|
99
|
+
name: 'get_document_structure',
|
|
100
|
+
description: 'List requirement ids and titles of one spec document.',
|
|
101
|
+
parameters: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: { spec_id: { type: 'string', description: 'Spec id from list_specs()' } },
|
|
104
|
+
required: ['spec_id'],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: 'function',
|
|
110
|
+
function: {
|
|
111
|
+
name: 'get_spec_content',
|
|
112
|
+
description: 'Read requirement statements of one spec by req ids.',
|
|
113
|
+
parameters: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
spec_id: { type: 'string' },
|
|
117
|
+
req_ids: { type: 'array', items: { type: 'string' }, description: 'e.g. ["r3"]' },
|
|
118
|
+
},
|
|
119
|
+
required: ['spec_id', 'req_ids'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
export interface TreeToolDeps {
|
|
126
|
+
tree: SerializedTreeIndex;
|
|
127
|
+
readFile: (path: string) => string;
|
|
128
|
+
root: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function executeTool(deps: TreeToolDeps, name: string, argsJson: string): string {
|
|
132
|
+
const args = JSON.parse(argsJson || '{}') as { spec_id?: string; req_ids?: string[] };
|
|
133
|
+
const doc = deps.tree.docs.find((d) => d.spec_id === args.spec_id);
|
|
134
|
+
switch (name) {
|
|
135
|
+
case 'list_specs':
|
|
136
|
+
return JSON.stringify(
|
|
137
|
+
deps.tree.docs.map((d) => ({ spec_id: d.spec_id, purpose: d.purpose })),
|
|
138
|
+
);
|
|
139
|
+
case 'get_document_structure':
|
|
140
|
+
if (!doc) return `unknown spec_id: ${args.spec_id}`;
|
|
141
|
+
return JSON.stringify(doc.reqs.map((r) => ({ req_id: r.req_id, title: r.title })));
|
|
142
|
+
case 'get_spec_content': {
|
|
143
|
+
if (!doc) return `unknown spec_id: ${args.spec_id}`;
|
|
144
|
+
const wanted = new Set(args.req_ids ?? []);
|
|
145
|
+
const reqs = doc.reqs.filter((r) => wanted.has(r.req_id));
|
|
146
|
+
if (reqs.length === 0) return `no matching req_ids in ${args.spec_id}`;
|
|
147
|
+
return reqs.map((r) => `[${r.req_id}] ${r.title}\n${r.statement}`).join('\n\n');
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return `unknown tool: ${name}`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseTiers(content: string): { direct: TierEntry[]; related: TierEntry[] } {
|
|
155
|
+
const start = content.indexOf('{');
|
|
156
|
+
const end = content.lastIndexOf('}');
|
|
157
|
+
if (start === -1 || end === -1)
|
|
158
|
+
throw new Error(`model output is not JSON: ${content.slice(0, 120)}`);
|
|
159
|
+
const parsed = JSON.parse(content.slice(start, end + 1)) as {
|
|
160
|
+
direct?: TierEntry[];
|
|
161
|
+
related?: TierEntry[];
|
|
162
|
+
};
|
|
163
|
+
return { direct: parsed.direct ?? [], related: parsed.related ?? [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* r28: the model may classify one spec into both tiers (observed with real
|
|
168
|
+
* models; v1 passes duplicates through). Cross-tier duplicates keep the direct
|
|
169
|
+
* entry, in-tier duplicates keep the first occurrence; summary counts are
|
|
170
|
+
* computed after this runs.
|
|
171
|
+
*/
|
|
172
|
+
function dedupTiers(tiers: { direct: TierEntry[]; related: TierEntry[] }): {
|
|
173
|
+
direct: TierEntry[];
|
|
174
|
+
related: TierEntry[];
|
|
175
|
+
} {
|
|
176
|
+
const seenDirect = new Set<string>();
|
|
177
|
+
const direct: TierEntry[] = [];
|
|
178
|
+
for (const entry of tiers.direct) {
|
|
179
|
+
if (seenDirect.has(entry.id)) continue;
|
|
180
|
+
seenDirect.add(entry.id);
|
|
181
|
+
direct.push(entry);
|
|
182
|
+
}
|
|
183
|
+
const related: TierEntry[] = [];
|
|
184
|
+
for (const entry of tiers.related) {
|
|
185
|
+
if (seenDirect.has(entry.id)) continue;
|
|
186
|
+
if (related.some((kept) => kept.id === entry.id)) continue;
|
|
187
|
+
related.push(entry);
|
|
188
|
+
}
|
|
189
|
+
return { direct, related };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface RetrieveDeps extends TreeToolDeps {
|
|
193
|
+
config: ChatConfig;
|
|
194
|
+
task: string;
|
|
195
|
+
paths?: string;
|
|
196
|
+
top?: number;
|
|
197
|
+
maxRounds?: number;
|
|
198
|
+
fetchImpl?: typeof fetch;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runContextRetrieval(deps: RetrieveDeps): Promise<ContextResult> {
|
|
202
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
203
|
+
const maxRounds = deps.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
204
|
+
const messages: Record<string, unknown>[] = [
|
|
205
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
206
|
+
{
|
|
207
|
+
role: 'user',
|
|
208
|
+
content: `Task: ${deps.task}${deps.paths ? `\nPaths: ${deps.paths}` : ''}`,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
let tiers: { direct: TierEntry[]; related: TierEntry[] } | null = null;
|
|
213
|
+
let toolCalls = 0;
|
|
214
|
+
try {
|
|
215
|
+
for (let round = 0; round <= maxRounds && tiers === null; round += 1) {
|
|
216
|
+
const forceFinal = round === maxRounds;
|
|
217
|
+
const body: Record<string, unknown> = {
|
|
218
|
+
model: deps.config.model,
|
|
219
|
+
messages,
|
|
220
|
+
...(forceFinal ? {} : { tools: TOOL_SCHEMAS, tool_choice: 'auto' }),
|
|
221
|
+
};
|
|
222
|
+
const resp = await fetchImpl(`${deps.config.host}/chat/completions`, {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: {
|
|
225
|
+
'content-type': 'application/json',
|
|
226
|
+
authorization: `Bearer ${deps.config.apiKey}`,
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
});
|
|
230
|
+
if (!resp.ok) {
|
|
231
|
+
const text = await resp.text();
|
|
232
|
+
return errorResult(`chat API ${resp.status}: ${text.slice(0, 200)}`, 'api_error');
|
|
233
|
+
}
|
|
234
|
+
const data = (await resp.json()) as {
|
|
235
|
+
choices: {
|
|
236
|
+
message: {
|
|
237
|
+
content: string | null;
|
|
238
|
+
tool_calls?: {
|
|
239
|
+
id: string;
|
|
240
|
+
type: string;
|
|
241
|
+
function: { name: string; arguments: string };
|
|
242
|
+
}[];
|
|
243
|
+
};
|
|
244
|
+
}[];
|
|
245
|
+
};
|
|
246
|
+
const message = data.choices?.[0]?.message;
|
|
247
|
+
if (!message) throw new Error('chat API returned no message');
|
|
248
|
+
|
|
249
|
+
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
250
|
+
toolCalls += message.tool_calls.length;
|
|
251
|
+
messages.push({
|
|
252
|
+
role: 'assistant',
|
|
253
|
+
content: message.content ?? '',
|
|
254
|
+
tool_calls: message.tool_calls,
|
|
255
|
+
});
|
|
256
|
+
for (const call of message.tool_calls) {
|
|
257
|
+
const result = executeTool(deps, call.function.name, call.function.arguments);
|
|
258
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
tiers = parseTiers(message.content ?? '');
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// v1 print_err semantics: network/transport/parse failures degrade to
|
|
266
|
+
// unavailable + api_error, never crash the CLI without JSON output.
|
|
267
|
+
return errorResult(`retrieval failed: ${(error as Error).message}`, 'api_error');
|
|
268
|
+
}
|
|
269
|
+
if (tiers === null) {
|
|
270
|
+
// r29: loop exhaustion degrades to agentic + truncation note + empty
|
|
271
|
+
// tiers (v1 truncated RetrievalOutput), with the success summary shape.
|
|
272
|
+
const noTiers: TierEntry[] = [];
|
|
273
|
+
return {
|
|
274
|
+
status: {
|
|
275
|
+
ok: true,
|
|
276
|
+
quality: 'agentic',
|
|
277
|
+
qualityNote: `agentic loop hit the ${maxRounds}-round tool-call limit; result may be incomplete`,
|
|
278
|
+
},
|
|
279
|
+
direct: noTiers,
|
|
280
|
+
related: [],
|
|
281
|
+
summary: successSummary(deps.tree.docs.length, noTiers, [], toolCalls, deps.paths),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const deduped = dedupTiers(tiers);
|
|
285
|
+
const direct = deduped.direct.slice(0, deps.top ?? 5);
|
|
286
|
+
const related = deduped.related.slice(0, deps.top ?? 5);
|
|
287
|
+
return {
|
|
288
|
+
status: { ok: true, quality: 'agentic', qualityNote: 'pageindex' },
|
|
289
|
+
direct,
|
|
290
|
+
related,
|
|
291
|
+
summary: successSummary(deps.tree.docs.length, direct, related, toolCalls, deps.paths),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function successSummary(
|
|
296
|
+
totalSpecs: number,
|
|
297
|
+
direct: TierEntry[],
|
|
298
|
+
related: TierEntry[],
|
|
299
|
+
toolCalls: number,
|
|
300
|
+
paths?: string,
|
|
301
|
+
): ContextSuccessSummary {
|
|
302
|
+
const pathList = paths
|
|
303
|
+
? paths
|
|
304
|
+
.split(',')
|
|
305
|
+
.map((p) => p.trim())
|
|
306
|
+
.filter((p) => p !== '')
|
|
307
|
+
: [];
|
|
308
|
+
return {
|
|
309
|
+
totalSpecs,
|
|
310
|
+
tierDirect: direct.length,
|
|
311
|
+
tierRelated: related.length,
|
|
312
|
+
unrelatedCount: Math.max(0, totalSpecs - direct.length - related.length),
|
|
313
|
+
toolCalls,
|
|
314
|
+
staleWarnings: [],
|
|
315
|
+
readRecommended: direct.map((d) => d.id),
|
|
316
|
+
paths: pathList,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function errorResult(qualityNote: string, errorKind: string): ContextResult {
|
|
321
|
+
return {
|
|
322
|
+
status: { ok: false, quality: 'unavailable', qualityNote, errorKind },
|
|
323
|
+
direct: [],
|
|
324
|
+
related: [],
|
|
325
|
+
summary: { totalSpecs: 0, error: true },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function unavailableResult(): ContextResult {
|
|
330
|
+
return errorResult(
|
|
331
|
+
'LLMAN_SDD_INDEX_CHAT_MODEL unset; set a tool-calling chat model: LLMAN_SDD_INDEX_CHAT_MODEL is required for the pageindex backend (agentic retrieval needs a chat model that supports tool/function calling)',
|
|
332
|
+
'api_error',
|
|
333
|
+
);
|
|
334
|
+
}
|