@planu/cli 4.6.1 → 4.7.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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/config/project-knowledge-graph.json +65 -0
  3. package/dist/engine/project-graph/builder.d.ts +7 -0
  4. package/dist/engine/project-graph/builder.js +92 -0
  5. package/dist/engine/project-graph/cache.d.ts +26 -0
  6. package/dist/engine/project-graph/cache.js +160 -0
  7. package/dist/engine/project-graph/extractors/git-extractor.d.ts +7 -0
  8. package/dist/engine/project-graph/extractors/git-extractor.js +89 -0
  9. package/dist/engine/project-graph/extractors/handoff-extractor.d.ts +3 -0
  10. package/dist/engine/project-graph/extractors/handoff-extractor.js +55 -0
  11. package/dist/engine/project-graph/extractors/spec-extractor.d.ts +3 -0
  12. package/dist/engine/project-graph/extractors/spec-extractor.js +189 -0
  13. package/dist/engine/project-graph/extractors/validation-extractor.d.ts +3 -0
  14. package/dist/engine/project-graph/extractors/validation-extractor.js +36 -0
  15. package/dist/engine/project-graph/index.d.ts +4 -0
  16. package/dist/engine/project-graph/index.js +4 -0
  17. package/dist/engine/project-graph/query.d.ts +9 -0
  18. package/dist/engine/project-graph/query.js +161 -0
  19. package/dist/tools/create-spec/post-creation.js +13 -1
  20. package/dist/tools/package-handoff.js +31 -2
  21. package/dist/tools/schemas/index.d.ts +1 -0
  22. package/dist/tools/schemas/index.js +1 -0
  23. package/dist/tools/schemas/project-graph.d.ts +18 -0
  24. package/dist/tools/schemas/project-graph.js +8 -0
  25. package/dist/tools/schemas/token-intelligence.d.ts +1 -0
  26. package/dist/tools/schemas/token-intelligence.js +3 -2
  27. package/dist/tools/status-handler.js +9 -2
  28. package/dist/tools/token-intelligence-handler.js +28 -1
  29. package/dist/tools/validate.js +75 -30
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/index.js +1 -0
  32. package/dist/types/project-knowledge-graph.d.ts +139 -0
  33. package/dist/types/project-knowledge-graph.js +2 -0
  34. package/package.json +12 -11
  35. package/planu-native.json +1 -1
  36. package/planu-plugin.json +1 -1
@@ -0,0 +1,189 @@
1
+ import YAML from 'yaml';
2
+ import { hashText } from '../cache.js';
3
+ import { redactGraphText } from '../query.js';
4
+ function frontmatter(content) {
5
+ if (!content.startsWith('---')) {
6
+ return {};
7
+ }
8
+ const end = content.indexOf('\n---', 3);
9
+ if (end === -1) {
10
+ return {};
11
+ }
12
+ try {
13
+ const parsed = YAML.parse(content.slice(3, end));
14
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ function asString(value) {
21
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
22
+ }
23
+ function criterionTexts(fm, content) {
24
+ const raw = Array.isArray(fm.criteria) ? fm.criteria : [];
25
+ const fromFrontmatter = raw
26
+ .map((item) => typeof item === 'object' && item !== null && 'text' in item
27
+ ? asString(item.text)
28
+ : undefined)
29
+ .filter((item) => item !== undefined);
30
+ if (fromFrontmatter.length > 0) {
31
+ return fromFrontmatter;
32
+ }
33
+ return [...content.matchAll(/^- \[[ x]\]\s+(.+)$/gim)]
34
+ .map((match) => (match[1] ?? '').trim())
35
+ .filter((item) => item.length > 0);
36
+ }
37
+ function extractBacktickPaths(text, prefix) {
38
+ const idx = text.indexOf(prefix);
39
+ if (idx === -1) {
40
+ return [];
41
+ }
42
+ const fragment = text.slice(idx + prefix.length);
43
+ return [...fragment.matchAll(/`([^`]+)`/g)]
44
+ .map((match) => (match[1] ?? '').trim())
45
+ .filter((item) => item.length > 0);
46
+ }
47
+ function uniqueById(items) {
48
+ return [...new Map(items.map((item) => [item.id, item])).values()];
49
+ }
50
+ function node(args) {
51
+ return {
52
+ id: args.id,
53
+ type: args.type,
54
+ label: redactGraphText(args.label, args.policy),
55
+ source: 'spec-extractor',
56
+ evidence: [{ path: args.source.path, hash: args.source.hash }],
57
+ metadata: args.metadata,
58
+ updatedAt: new Date().toISOString(),
59
+ };
60
+ }
61
+ function edge(args) {
62
+ const id = `edge:${hashText(`${args.from}:${args.type}:${args.to}:${args.selector ?? ''}`)}`;
63
+ return {
64
+ id,
65
+ from: args.from,
66
+ to: args.to,
67
+ type: args.type,
68
+ source: 'spec-extractor',
69
+ confidence: args.confidence ?? 'high',
70
+ classification: args.classification ?? 'extracted',
71
+ evidence: { path: args.source.path, selector: args.selector, hash: args.source.hash },
72
+ updatedAt: new Date().toISOString(),
73
+ };
74
+ }
75
+ function sectionBullets(content, heading) {
76
+ const pattern = new RegExp(`^##+\\s+${heading}\\s*$`, 'im');
77
+ const match = pattern.exec(content);
78
+ if (!match) {
79
+ return [];
80
+ }
81
+ const start = match.index + match[0].length;
82
+ const rest = content.slice(start);
83
+ const nextHeading = rest.search(/^##+\s+/m);
84
+ const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
85
+ return [...section.matchAll(/^-+\s+(.+)$/gm)]
86
+ .map((item) => (item[1] ?? '').trim())
87
+ .filter((item) => item.length > 0)
88
+ .slice(0, 20);
89
+ }
90
+ function appendRiskAndDecisionNodes(args) {
91
+ for (const [index, risk] of [
92
+ ...sectionBullets(args.source.content, 'Risks'),
93
+ ...sectionBullets(args.source.content, 'Edge Cases And Failure Modes'),
94
+ ].entries()) {
95
+ const riskId = `risk:${args.specId}:${String(index + 1)}`;
96
+ args.nodes.push(node({
97
+ id: riskId,
98
+ type: 'risk',
99
+ label: risk,
100
+ source: args.source,
101
+ policy: args.policy,
102
+ metadata: { specId: args.specId },
103
+ }));
104
+ args.edges.push(edge({ from: args.specNodeId, to: riskId, type: 'has_risk', source: args.source }));
105
+ }
106
+ for (const [index, decision] of [
107
+ ...sectionBullets(args.source.content, 'Hard constraints'),
108
+ ...sectionBullets(args.source.content, 'Non-Goals And Forbidden Approaches'),
109
+ ].entries()) {
110
+ const decisionId = `decision:${args.specId}:${String(index + 1)}`;
111
+ args.nodes.push(node({
112
+ id: decisionId,
113
+ type: 'decision',
114
+ label: decision,
115
+ source: args.source,
116
+ policy: args.policy,
117
+ metadata: { specId: args.specId },
118
+ }));
119
+ args.edges.push(edge({ from: args.specNodeId, to: decisionId, type: 'decides', source: args.source }));
120
+ }
121
+ }
122
+ function appendToolNodes(args) {
123
+ for (const toolName of args.policy.toolNamePatterns) {
124
+ if (args.source.content.includes(toolName)) {
125
+ const toolId = `tool:${toolName}`;
126
+ args.nodes.push(node({
127
+ id: toolId,
128
+ type: 'tool',
129
+ label: toolName,
130
+ source: args.source,
131
+ policy: args.policy,
132
+ }));
133
+ args.edges.push(edge({
134
+ from: args.specNodeId,
135
+ to: toolId,
136
+ type: 'uses_tool',
137
+ source: args.source,
138
+ confidence: 'medium',
139
+ classification: 'inferred',
140
+ }));
141
+ }
142
+ }
143
+ }
144
+ export function extractSpecGraph(source, policy) {
145
+ const fm = frontmatter(source.content);
146
+ const specId = asString(fm.id) ?? source.path.split('/').find((part) => /^SPEC-\d+/.test(part));
147
+ if (specId === undefined) {
148
+ return { nodes: [], edges: [] };
149
+ }
150
+ const title = asString(fm.title) ?? specId;
151
+ const specNodeId = `spec:${specId}`;
152
+ const nodes = [
153
+ node({
154
+ id: specNodeId,
155
+ type: 'spec',
156
+ label: `${specId} ${title}`,
157
+ source,
158
+ policy,
159
+ metadata: { specId, status: asString(fm.status) ?? null },
160
+ }),
161
+ ];
162
+ const edges = [];
163
+ for (const [index, text] of criterionTexts(fm, source.content).entries()) {
164
+ const criterionId = `criterion:${specId}:${String(index + 1)}`;
165
+ nodes.push(node({
166
+ id: criterionId,
167
+ type: 'criterion',
168
+ label: text,
169
+ source,
170
+ policy,
171
+ metadata: { specId, index: index + 1 },
172
+ }));
173
+ edges.push(edge({ from: specNodeId, to: criterionId, type: 'contains', source }));
174
+ for (const file of extractBacktickPaths(text, 'FILES:')) {
175
+ const fileId = `file:${file}`;
176
+ nodes.push(node({ id: fileId, type: 'file', label: file, source, policy }));
177
+ edges.push(edge({ from: criterionId, to: fileId, type: 'implements', source }));
178
+ }
179
+ for (const test of extractBacktickPaths(text, 'TEST:')) {
180
+ const testId = `test:${test}`;
181
+ nodes.push(node({ id: testId, type: 'test', label: test, source, policy }));
182
+ edges.push(edge({ from: criterionId, to: testId, type: 'tests', source }));
183
+ }
184
+ }
185
+ appendRiskAndDecisionNodes({ nodes, edges, specNodeId, specId, source, policy });
186
+ appendToolNodes({ nodes, edges, specNodeId, source, policy });
187
+ return { nodes: uniqueById(nodes), edges: uniqueById(edges) };
188
+ }
189
+ //# sourceMappingURL=spec-extractor.js.map
@@ -0,0 +1,3 @@
1
+ import type { ProjectGraphExtractionResult, ProjectGraphPolicy, ProjectGraphSource } from '../../../types/project-knowledge-graph.js';
2
+ export declare function extractValidationGraph(source: ProjectGraphSource, policy: ProjectGraphPolicy): ProjectGraphExtractionResult;
3
+ //# sourceMappingURL=validation-extractor.d.ts.map
@@ -0,0 +1,36 @@
1
+ import { hashText } from '../cache.js';
2
+ import { redactGraphText } from '../query.js';
3
+ function specIdFromPath(path) {
4
+ return path.split('/').find((part) => /^SPEC-\d+$/i.test(part));
5
+ }
6
+ function basename(path) {
7
+ return path.split('/').slice(-1)[0] ?? path;
8
+ }
9
+ export function extractValidationGraph(source, policy) {
10
+ const specId = specIdFromPath(source.path);
11
+ if (specId === undefined || !source.path.includes('validation')) {
12
+ return { nodes: [], edges: [] };
13
+ }
14
+ const validationId = `validation:${specId}:${hashText(source.path).slice(0, 10)}`;
15
+ const node = {
16
+ id: validationId,
17
+ type: 'validation',
18
+ label: redactGraphText(basename(source.path), policy),
19
+ source: 'validation-extractor',
20
+ evidence: [{ path: source.path, hash: source.hash }],
21
+ updatedAt: new Date().toISOString(),
22
+ };
23
+ const edge = {
24
+ id: `edge:${hashText(`spec:${specId}:validated_by:${validationId}`)}`,
25
+ from: `spec:${specId}`,
26
+ to: validationId,
27
+ type: 'validated_by',
28
+ source: 'validation-extractor',
29
+ confidence: 'medium',
30
+ classification: 'extracted',
31
+ evidence: { path: source.path, hash: source.hash },
32
+ updatedAt: new Date().toISOString(),
33
+ };
34
+ return { nodes: [node], edges: [edge] };
35
+ }
36
+ //# sourceMappingURL=validation-extractor.js.map
@@ -0,0 +1,4 @@
1
+ export { buildProjectKnowledgeGraph } from './builder.js';
2
+ export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint, redactGraphText, } from './query.js';
3
+ export { getProjectGraphFreshness, loadProjectGraphPolicy, projectGraphPath, projectGraphCachePath, } from './cache.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export { buildProjectKnowledgeGraph } from './builder.js';
2
+ export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint, redactGraphText, } from './query.js';
3
+ export { getProjectGraphFreshness, loadProjectGraphPolicy, projectGraphPath, projectGraphCachePath, } from './cache.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ import type { ProjectGraphPolicy, ProjectGraphQueryInput, ProjectGraphSlice } from '../../types/project-knowledge-graph.js';
2
+ export declare function redactGraphText(text: string, policy: ProjectGraphPolicy, maxChars?: number): string;
3
+ export declare function queryProjectGraphSlice(input: ProjectGraphQueryInput): Promise<ProjectGraphSlice | null>;
4
+ export declare function formatProjectGraphContext(slice: ProjectGraphSlice): Promise<string>;
5
+ export declare function getProjectGraphFreshnessHint(args: {
6
+ projectId?: string;
7
+ projectPath: string;
8
+ }): Promise<string | undefined>;
9
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1,161 @@
1
+ import { hashProjectPath } from '../../storage/base-store.js';
2
+ import { buildProjectKnowledgeGraph } from './builder.js';
3
+ import { getProjectGraphFreshness, loadProjectGraphPolicy, readProjectGraph } from './cache.js';
4
+ function escapeInert(text) {
5
+ return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
6
+ }
7
+ export function redactGraphText(text, policy, maxChars = policy.redaction.maxSnippetChars) {
8
+ const truncated = text.slice(0, maxChars);
9
+ return policy.redaction.redactPatterns.reduce((current, pattern) => {
10
+ const re = new RegExp(pattern, 'gi');
11
+ return current.replace(re, '[redacted]');
12
+ }, truncated);
13
+ }
14
+ function sanitizeLabel(label, policy) {
15
+ return escapeInert(redactGraphText(label, policy));
16
+ }
17
+ function relatedNodeIds(nodes, edges, input) {
18
+ const seeds = new Set();
19
+ for (const node of nodes) {
20
+ const specMatch = input.specId !== undefined &&
21
+ (node.id === `spec:${input.specId}` || node.metadata?.specId === input.specId);
22
+ const fileMatch = input.filePath !== undefined &&
23
+ (node.id === `file:${input.filePath}` || node.label === input.filePath);
24
+ const nodeMatch = input.nodeId !== undefined && node.id === input.nodeId;
25
+ if (specMatch || fileMatch || nodeMatch) {
26
+ seeds.add(node.id);
27
+ }
28
+ }
29
+ if (seeds.size === 0 && input.specId === undefined && input.filePath === undefined) {
30
+ for (const node of nodes.slice(0, 20)) {
31
+ seeds.add(node.id);
32
+ }
33
+ }
34
+ const related = new Set(seeds);
35
+ for (const edge of edges) {
36
+ if (seeds.has(edge.from)) {
37
+ related.add(edge.to);
38
+ }
39
+ if (seeds.has(edge.to)) {
40
+ related.add(edge.from);
41
+ }
42
+ }
43
+ return related;
44
+ }
45
+ function summarize(nodes) {
46
+ const count = (type) => nodes.filter((node) => node.type === type).length;
47
+ return {
48
+ specs: count('spec'),
49
+ criteria: count('criterion'),
50
+ files: count('file'),
51
+ tests: count('test'),
52
+ decisions: count('decision'),
53
+ risks: count('risk'),
54
+ tools: count('tool'),
55
+ releases: count('release'),
56
+ };
57
+ }
58
+ export async function queryProjectGraphSlice(input) {
59
+ const projectId = input.projectId ?? hashProjectPath(input.projectPath);
60
+ const policy = await loadProjectGraphPolicy();
61
+ if (!policy.enabled) {
62
+ return null;
63
+ }
64
+ let freshness = await getProjectGraphFreshness({
65
+ projectId,
66
+ projectPath: input.projectPath,
67
+ policy,
68
+ });
69
+ if (!freshness.exists ||
70
+ freshness.reason === 'source_changed' ||
71
+ freshness.reason === 'corrupt') {
72
+ await buildProjectKnowledgeGraph({ projectId, projectPath: input.projectPath, policy });
73
+ freshness = await getProjectGraphFreshness({
74
+ projectId,
75
+ projectPath: input.projectPath,
76
+ policy,
77
+ });
78
+ }
79
+ const graph = await readProjectGraph(projectId, policy);
80
+ if (graph === null) {
81
+ return null;
82
+ }
83
+ const maxNodes = input.maxNodes ?? policy.query.maxNodes;
84
+ const maxEdges = input.maxEdges ?? policy.query.maxEdges;
85
+ const ids = relatedNodeIds(graph.nodes, graph.edges, input);
86
+ const nodes = graph.nodes.filter((node) => ids.has(node.id)).slice(0, maxNodes);
87
+ const nodeIds = new Set(nodes.map((node) => node.id));
88
+ const edges = graph.edges
89
+ .filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to))
90
+ .slice(0, maxEdges);
91
+ return {
92
+ graphVersion: graph.graphVersion,
93
+ generatedAt: graph.generatedAt,
94
+ freshness,
95
+ nodes,
96
+ edges,
97
+ summary: summarize(nodes),
98
+ tokenSavings: {
99
+ compactNodes: nodes.length,
100
+ compactEdges: edges.length,
101
+ estimatedRawContextAvoided: Math.max(0, graph.nodes.length + graph.edges.length - nodes.length - edges.length),
102
+ },
103
+ };
104
+ }
105
+ function listByType(slice, type, policy) {
106
+ return slice.nodes
107
+ .filter((node) => node.type === type)
108
+ .map((node) => sanitizeLabel(node.label, policy))
109
+ .slice(0, policy.query.maxListItems);
110
+ }
111
+ export async function formatProjectGraphContext(slice) {
112
+ const policy = await loadProjectGraphPolicy();
113
+ const files = listByType(slice, 'file', policy);
114
+ const tests = listByType(slice, 'test', policy);
115
+ const decisions = listByType(slice, 'decision', policy);
116
+ const risks = listByType(slice, 'risk', policy);
117
+ const specs = listByType(slice, 'spec', policy);
118
+ const tools = listByType(slice, 'tool', policy);
119
+ const lines = ['## Graph Context', ''];
120
+ lines.push(`Freshness: ${slice.freshness.stale ? `stale (${slice.freshness.reason})` : 'fresh'}; compact slice: ${String(slice.nodes.length)} nodes / ${String(slice.edges.length)} edges.`);
121
+ if (files.length > 0) {
122
+ lines.push(`Related files: ${files.join(', ')}`);
123
+ }
124
+ if (tests.length > 0) {
125
+ lines.push(`Related tests: ${tests.join(', ')}`);
126
+ }
127
+ if (decisions.length > 0) {
128
+ lines.push(`Decisions: ${decisions.join('; ')}`);
129
+ }
130
+ if (risks.length > 0) {
131
+ lines.push(`Risks: ${risks.join('; ')}`);
132
+ }
133
+ if (specs.length > 1) {
134
+ lines.push(`Neighboring specs: ${specs.join(', ')}`);
135
+ }
136
+ if (tools.length > 0) {
137
+ lines.push(`Tools: ${tools.join(', ')}`);
138
+ }
139
+ lines.push('');
140
+ return lines.join('\n');
141
+ }
142
+ export async function getProjectGraphFreshnessHint(args) {
143
+ const projectId = args.projectId ?? hashProjectPath(args.projectPath);
144
+ const policy = await loadProjectGraphPolicy();
145
+ if (!policy.enabled) {
146
+ return undefined;
147
+ }
148
+ const freshness = await getProjectGraphFreshness({
149
+ projectId,
150
+ projectPath: args.projectPath,
151
+ policy,
152
+ });
153
+ if (!freshness.stale) {
154
+ return undefined;
155
+ }
156
+ const suffix = freshness.changedSources.length > 0
157
+ ? `; ${String(freshness.changedSources.length)} changed source(s)`
158
+ : '';
159
+ return `project graph ${freshness.reason}${suffix}`;
160
+ }
161
+ //# sourceMappingURL=query.js.map
@@ -13,6 +13,7 @@ import { join, dirname } from 'node:path';
13
13
  import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
14
14
  import { appendAutopilotLogEntry } from '../../storage/autopilot-log-store.js';
15
15
  import { analyzeContextPreflight, buildTokenWasteReport, loadTokenWastePolicy, recommendRelevantTools, toolsFromPolicyGroups, } from '../../engine/token-optimizer/index.js';
16
+ import { queryProjectGraphSlice } from '../../engine/project-graph/index.js';
16
17
  const ASYNC_ANALYSIS_HOOK = 'create-spec-async-analysis';
17
18
  export function getAsyncAnalysisPath(projectPath, specId) {
18
19
  const projectId = hashProjectPath(projectPath);
@@ -24,10 +25,21 @@ export async function buildInitialTokenWasteMetadata(specId, projectPath, descri
24
25
  if (!policy.enabled) {
25
26
  return null;
26
27
  }
28
+ const graphSlice = await queryProjectGraphSlice({ projectPath, specId }).catch(() => null);
29
+ const graphCandidates = graphSlice?.nodes
30
+ .filter((node) => node.type === 'file' || node.type === 'test')
31
+ .slice(0, 12)
32
+ .map((node) => ({
33
+ path: node.label,
34
+ reason: 'Related by Project Knowledge Graph compact slice.',
35
+ })) ?? [];
27
36
  const context = analyzeContextPreflight({
28
37
  action: 'create_spec',
29
38
  policy,
30
- candidates: [{ path: 'planu/specs', reason: 'New spec workspace metadata.' }],
39
+ candidates: [
40
+ ...graphCandidates,
41
+ { path: 'planu/specs', reason: 'New spec workspace metadata.' },
42
+ ],
31
43
  spec: { id: specId },
32
44
  });
33
45
  const tools = recommendRelevantTools({
@@ -5,6 +5,7 @@ import { packageHandoff } from '../engine/handoff-packager.js';
5
5
  import { detectParadigms } from '../engine/paradigm-detector.js';
6
6
  import { analyzeContextPreflight, buildTokenWasteReport, formatTokenWasteReport, loadTokenWastePolicy, recommendRelevantTools, toolsFromPolicyGroups, } from '../engine/token-optimizer/index.js';
7
7
  import { appendTransitionEvent } from '../storage/transition-log.js';
8
+ import { formatProjectGraphContext, queryProjectGraphSlice, } from '../engine/project-graph/index.js';
8
9
  // ── Formatting helpers ───────────────────────────────────────────────────────
9
10
  function formatHandoff(pkg) {
10
11
  const lines = [];
@@ -176,6 +177,25 @@ async function buildHandoffTokenWasteReport(pkg, knowledge, spec) {
176
177
  return null;
177
178
  }
178
179
  }
180
+ async function buildGraphContextSection(args) {
181
+ if (args.projectPath === undefined) {
182
+ return null;
183
+ }
184
+ try {
185
+ const slice = await queryProjectGraphSlice({
186
+ projectId: args.projectId,
187
+ projectPath: args.projectPath,
188
+ specId: args.specId,
189
+ });
190
+ if (slice === null || slice.nodes.length === 0) {
191
+ return null;
192
+ }
193
+ return { text: await formatProjectGraphContext(slice), structured: slice };
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
179
199
  // ── Handler ──────────────────────────────────────────────────────────────────
180
200
  export async function handlePackageHandoff(args) {
181
201
  const { projectId, specId } = args;
@@ -231,9 +251,17 @@ export async function handlePackageHandoff(args) {
231
251
  constraints: paradigmConstraints,
232
252
  };
233
253
  const tokenWasteReport = await buildHandoffTokenWasteReport(pkgWithScore, knowledge, spec);
234
- const formatted = tokenWasteReport !== null
235
- ? `${formatHandoff(pkgWithScore)}\n${formatTokenWasteReport(tokenWasteReport)}`
254
+ const graphContext = await buildGraphContextSection({
255
+ projectId,
256
+ projectPath: knowledge.projectPath,
257
+ specId,
258
+ });
259
+ const handoffText = graphContext !== null
260
+ ? `${formatHandoff(pkgWithScore)}\n${graphContext.text}`
236
261
  : formatHandoff(pkgWithScore);
262
+ const formatted = tokenWasteReport !== null
263
+ ? `${handoffText}\n${formatTokenWasteReport(tokenWasteReport)}`
264
+ : handoffText;
237
265
  void appendTransitionEvent({
238
266
  projectId,
239
267
  specId,
@@ -255,6 +283,7 @@ export async function handlePackageHandoff(args) {
255
283
  handoffPath: pkgWithScore.handoffPath,
256
284
  contextHash: pkgWithScore.contextHash,
257
285
  ...(tokenWasteReport !== null ? { tokenWaste: tokenWasteReport } : {}),
286
+ ...(graphContext !== null ? { graphContext: graphContext.structured } : {}),
258
287
  },
259
288
  };
260
289
  }
@@ -13,6 +13,7 @@ export { SecurityReportTimeRangeEnum, SecurityReportFormatEnum } from './runtime
13
13
  export { WorkerStatusInputSchema, ConfigureWorkersInputSchema } from './workers-schema.js';
14
14
  export { TokenUsagePeriodEnum, TokenUsageGroupByEnum } from './token-optimization.js';
15
15
  export { TokenIntelligencePeriodEnum, TokenIntelligenceGroupByEnum, TokenIntelligenceViewEnum, } from './token-intelligence.js';
16
+ export { ProjectGraphActionEnum, ProjectGraphQueryKindEnum } from './project-graph.js';
16
17
  export { ConfigureLLMProvidersActionEnum, LoadBalancingStrategyEnum, } from './llm-provider-schemas.js';
17
18
  export { PluginActionEnum } from './plugins-schemas.js';
18
19
  export { ListSpecsOutputSchema, EstimateOutputSchema, ValidateOutputSchema, CheckReadinessOutputSchema, LicenseStatusOutputSchema, } from './output-schemas.js';
@@ -14,6 +14,7 @@ export { SecurityReportTimeRangeEnum, SecurityReportFormatEnum } from './runtime
14
14
  export { WorkerStatusInputSchema, ConfigureWorkersInputSchema } from './workers-schema.js';
15
15
  export { TokenUsagePeriodEnum, TokenUsageGroupByEnum } from './token-optimization.js';
16
16
  export { TokenIntelligencePeriodEnum, TokenIntelligenceGroupByEnum, TokenIntelligenceViewEnum, } from './token-intelligence.js';
17
+ export { ProjectGraphActionEnum, ProjectGraphQueryKindEnum } from './project-graph.js';
17
18
  export { ConfigureLLMProvidersActionEnum, LoadBalancingStrategyEnum, } from './llm-provider-schemas.js';
18
19
  export { PluginActionEnum } from './plugins-schemas.js';
19
20
  export { ListSpecsOutputSchema, EstimateOutputSchema, ValidateOutputSchema, CheckReadinessOutputSchema, LicenseStatusOutputSchema, } from './output-schemas.js';
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ export declare const ProjectGraphActionEnum: z.ZodEnum<{
3
+ status: "status";
4
+ query: "query";
5
+ explain: "explain";
6
+ rebuild: "rebuild";
7
+ }>;
8
+ export declare const ProjectGraphQueryKindEnum: z.ZodEnum<{
9
+ release: "release";
10
+ decision: "decision";
11
+ spec: "spec";
12
+ file: "file";
13
+ risk: "risk";
14
+ tool: "tool";
15
+ test: "test";
16
+ criterion: "criterion";
17
+ }>;
18
+ //# sourceMappingURL=project-graph.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export const ProjectGraphActionEnum = z
3
+ .enum(['status', 'query', 'explain', 'rebuild'])
4
+ .describe('Project graph inspection action.');
5
+ export const ProjectGraphQueryKindEnum = z
6
+ .enum(['spec', 'file', 'criterion', 'test', 'release', 'decision', 'risk', 'tool'])
7
+ .describe('Graph entity kind to query.');
8
+ //# sourceMappingURL=project-graph.js.map
@@ -12,6 +12,7 @@ export declare const TokenIntelligenceGroupByEnum: z.ZodEnum<{
12
12
  day: "day";
13
13
  }>;
14
14
  export declare const TokenIntelligenceViewEnum: z.ZodEnum<{
15
+ graph: "graph";
15
16
  summary: "summary";
16
17
  budget: "budget";
17
18
  detailed: "detailed";
@@ -9,11 +9,12 @@ export const TokenIntelligenceGroupByEnum = z
9
9
  .describe('Group results by: tool (per MCP tool name), model (per AI model), ' +
10
10
  'spec (per spec ID), day (daily breakdown)');
11
11
  export const TokenIntelligenceViewEnum = z
12
- .enum(['summary', 'detailed', 'budget', 'reconciliation', 'trends', 'autopilot'])
12
+ .enum(['summary', 'detailed', 'budget', 'reconciliation', 'trends', 'autopilot', 'graph'])
13
13
  .describe('Dashboard view: summary (high-level overview with top consumers), ' +
14
14
  'detailed (full per-tool and per-model breakdown), ' +
15
15
  'budget (spending vs monthly limits with projections), ' +
16
16
  'reconciliation (estimated vs actual cost accuracy per spec), ' +
17
17
  'trends (usage patterns over time, week-over-week changes, and anomaly alerts), ' +
18
- 'autopilot (policy-driven recommendations to reduce wasted context, output, and tool usage)');
18
+ 'autopilot (policy-driven recommendations to reduce wasted context, output, and tool usage), ' +
19
+ 'graph (project knowledge graph freshness, compact context savings, and stale graph risks)');
19
20
  //# sourceMappingURL=token-intelligence.js.map
@@ -18,6 +18,7 @@ import { readPlanuConfig } from '../engine/planu-config-writer.js';
18
18
  import { computePlanuDriftScore } from '../engine/dashboard/drift-score.js';
19
19
  import { findStaleBranches, findStaleWorktrees, findStaleStashes, } from '../engine/housekeeping/index.js';
20
20
  import { loadTokenWastePolicy } from '../engine/token-optimizer/index.js';
21
+ import { getProjectGraphFreshnessHint } from '../engine/project-graph/index.js';
21
22
  // SPEC-1011 Bug G: fire-and-forget status.json reconciliation
22
23
  import { reconcileStatusFromDisk } from '../engine/status-reconciler/index.js';
23
24
  const execFile = promisify(execFileCb);
@@ -128,7 +129,7 @@ function buildSuggestion(snapshot) {
128
129
  // Output builder
129
130
  // ---------------------------------------------------------------------------
130
131
  function buildOutput(params) {
131
- const { snapshot, git, ci, slaBreaches, workMode, updateBanner, autoCompleted, sessionTip, tokenWasteHint, } = params;
132
+ const { snapshot, git, ci, slaBreaches, workMode, updateBanner, autoCompleted, sessionTip, tokenWasteHint, graphFreshnessHint, } = params;
132
133
  const lines = ['Planu Status', '━━━━━━━━━━━━━━━'];
133
134
  if (snapshot.active !== null) {
134
135
  const title = snapshot.active.title.slice(0, 40);
@@ -166,6 +167,9 @@ function buildOutput(params) {
166
167
  if (tokenWasteHint !== undefined) {
167
168
  lines.push(`TOKEN WASTE ${tokenWasteHint}`);
168
169
  }
170
+ if (graphFreshnessHint !== undefined) {
171
+ lines.push(`GRAPH ${graphFreshnessHint}`);
172
+ }
169
173
  lines.push(`SUGGEST ${buildSuggestion(snapshot)}`);
170
174
  if (workMode !== undefined) {
171
175
  lines.push(`WORK MODE ${workMode}`);
@@ -210,7 +214,7 @@ export async function handlePlanStatus(args) {
210
214
  /* best-effort */
211
215
  });
212
216
  }
213
- const [snapshot, git, ci, slaBreaches, planuConfig, autoCompleted, sessionState, driftScore, pendingCleanup, tokenWastePolicy,] = await Promise.all([
217
+ const [snapshot, git, ci, slaBreaches, planuConfig, autoCompleted, sessionState, driftScore, pendingCleanup, tokenWastePolicy, graphFreshnessHint,] = await Promise.all([
214
218
  getStatusSpecSnapshot(projectId),
215
219
  getGitState(args.projectPath),
216
220
  getCiState(args.projectPath),
@@ -244,6 +248,7 @@ export async function handlePlanStatus(args) {
244
248
  loadTokenWastePolicy(args.projectPath)
245
249
  .then((loaded) => loaded.policy)
246
250
  .catch(() => null),
251
+ getProjectGraphFreshnessHint({ projectId, projectPath: args.projectPath }).catch(() => undefined),
247
252
  ]);
248
253
  // SPEC-1012: Detect stale release status (best-effort, non-blocking)
249
254
  let staleStatusWarnings = [];
@@ -277,6 +282,7 @@ export async function handlePlanStatus(args) {
277
282
  autoCompleted,
278
283
  sessionTip,
279
284
  tokenWasteHint,
285
+ graphFreshnessHint,
280
286
  });
281
287
  // SPEC-256: Always show /btw hint as educational tip (no context percent available from MCP layer)
282
288
  const DEMO_CONTEXT_PERCENT = 65;
@@ -301,6 +307,7 @@ export async function handlePlanStatus(args) {
301
307
  ...(autoCompleted.length > 0 ? { autoCompleted } : {}),
302
308
  ...(sessionTip !== undefined ? { sessionTip } : {}),
303
309
  ...(tokenWasteHint !== undefined ? { tokenWasteHint } : {}),
310
+ ...(graphFreshnessHint !== undefined ? { graphFreshnessHint } : {}),
304
311
  ...(btwHint !== undefined ? { btw_hint: btwHint } : {}),
305
312
  ...(updateBanner !== undefined ? { update_banner: updateBanner } : {}),
306
313
  // SPEC-751: pending cleanup counters