@planu/cli 4.9.0 → 4.10.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/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [4.10.0] - 2026-07-01
2
+
3
+ ### Features
4
+ - feat(SPEC-1100): add reliable feedback sync
5
+
6
+ ### Bug Fixes
7
+ - fix(SPEC-1102): honor release preflight test skip
8
+ - fix(SPEC-1101): harden validate runtime and status
9
+
10
+ ### Chores
11
+ - chore(deps): refresh direct dependencies
12
+
13
+
1
14
  ## [4.9.0] - 2026-06-30
2
15
 
3
16
  ### Features
@@ -92,6 +92,7 @@
92
92
  "export_spec",
93
93
  "expose_spec_as_prompt",
94
94
  "facilitate",
95
+ "feedback_status",
95
96
  "gc_data_projects",
96
97
  "generate_api_client",
97
98
  "generate_attestation",
@@ -163,6 +164,7 @@
163
164
  "render_spec_for_provider",
164
165
  "repair_frontmatter_drift",
165
166
  "request_changes",
167
+ "resolve_feedback",
166
168
  "reverse_engineer",
167
169
  "rewrite_criteria_ears",
168
170
  "run_mutation_hints",
@@ -189,13 +191,16 @@
189
191
  "suggest_token_optimizer",
190
192
  "suggest_tooling",
191
193
  "summarize_spec",
194
+ "submit_feedback",
192
195
  "sync_ai_configs",
196
+ "sync_feedback",
193
197
  "sync_spec_state",
194
198
  "tdd_scaffold",
195
199
  "tdd_status",
196
200
  "telemetry_health",
197
201
  "token_optimizer_status",
198
202
  "transform_code",
203
+ "triage_feedback",
199
204
  "triage_request",
200
205
  "type_safety_gate",
201
206
  "unregister_project_path",
@@ -294,6 +294,8 @@
294
294
  "submit_feedback",
295
295
  "triage_feedback",
296
296
  "resolve_feedback",
297
+ "sync_feedback",
298
+ "feedback_status",
297
299
  "delete_spec",
298
300
  "delete_project",
299
301
  "delete_pattern",
@@ -373,10 +375,7 @@
373
375
  "analyze_performance_impact",
374
376
  "recommend_model"
375
377
  ],
376
- "unsupportedTools": [
377
- "challenge_spec",
378
- "red_team"
379
- ],
378
+ "unsupportedTools": ["challenge_spec", "red_team"],
380
379
  "restrictedTools": {
381
380
  "challenge_spec": "Requires interactive UI — use planu_facilitate instead",
382
381
  "approve_spec": "Requires reviewer token — use planu_facilitate instead"
@@ -37,16 +37,16 @@ async function collectEvidence(args) {
37
37
  records.push(record('scenario', row.scenario, 'valid', 'traceability-matrix'));
38
38
  }
39
39
  for (const test of row.testEvidence ?? []) {
40
- records.push(await pathRecord('test', test, 'traceability-matrix', args));
40
+ records.push(await pathRecord('test', test, 'traceability-matrix', args, 'testEvidence'));
41
41
  }
42
42
  for (const contract of row.contractEvidence ?? []) {
43
- records.push(await pathRecord('contract', contract, 'traceability-matrix', args));
43
+ records.push(await pathRecord('contract', contract, 'traceability-matrix', args, 'contractEvidence'));
44
44
  }
45
45
  if (row.manualEvidence) {
46
46
  records.push(record('manual', row.manualEvidence, 'valid', 'traceability-matrix'));
47
47
  }
48
48
  for (const changedFile of row.changedFiles) {
49
- records.push(await pathRecord('changed-file', changedFile, 'traceability-matrix', args));
49
+ records.push(await pathRecord('changed-file', changedFile, 'traceability-matrix', args, 'changedFiles'));
50
50
  }
51
51
  if (row.validationEvidence) {
52
52
  records.push(validationRecord(row.validationEvidence));
@@ -57,7 +57,7 @@ async function collectEvidence(args) {
57
57
  }
58
58
  for (const validation of args.contractValidations) {
59
59
  if (validation.reportPath) {
60
- records.push(await pathRecord('contract', validation.reportPath, `contract-validation:${validation.kind}`, args));
60
+ records.push(await pathRecord('contract', validation.reportPath, `contract-validation:${validation.kind}`, args, 'contractValidation.reportPath'));
61
61
  }
62
62
  if (validation.summary) {
63
63
  records.push({
@@ -95,36 +95,62 @@ function parseValidationEvidence(value) {
95
95
  return { command: value };
96
96
  }
97
97
  }
98
- async function pathRecord(kind, value, source, args) {
99
- const safe = resolveSafeProjectPath(value, args.projectPath);
98
+ async function pathRecord(kind, value, source, args, fieldName) {
99
+ const safe = resolveSafeProjectPath(value, args.projectPath, fieldName);
100
100
  if (!safe.ok) {
101
101
  args.diagnostics.push(`${value}: ${safe.reason}`);
102
102
  return record(kind, value, 'invalid', source, safe.reason);
103
103
  }
104
104
  if (!args.projectPath) {
105
- return record(kind, value, 'missing', source, 'projectPath unavailable');
105
+ return record(kind, value, 'missing', source, evidencePathReason(fieldName, 'projectPath unavailable'));
106
106
  }
107
107
  const exists = await stat(safe.path)
108
108
  .then(() => true)
109
109
  .catch(() => false);
110
- return record(kind, value, exists ? 'valid' : 'stale', source, exists ? undefined : 'file missing');
110
+ const missingReason = evidencePathReason(fieldName, `Missing path: ${value}`);
111
+ return record(kind, value, exists ? 'valid' : 'stale', source, exists ? undefined : missingReason);
111
112
  }
112
- function resolveSafeProjectPath(value, projectPath) {
113
+ function resolveSafeProjectPath(value, projectPath, fieldName) {
113
114
  if (value.trim().length === 0) {
114
- return { ok: false, reason: 'empty evidence path' };
115
+ return { ok: false, reason: evidencePathReason(fieldName, 'Empty path.') };
116
+ }
117
+ if (looksLikeShellCommand(value)) {
118
+ return { ok: false, reason: evidencePathReason(fieldName, 'Commands are not valid paths.') };
119
+ }
120
+ if (looksLikeGlob(value)) {
121
+ return { ok: false, reason: evidencePathReason(fieldName, 'Globs are not valid paths.') };
115
122
  }
116
123
  if (isAbsolute(value)) {
117
- return { ok: false, reason: 'absolute evidence paths are not allowed' };
124
+ return { ok: false, reason: evidencePathReason(fieldName, 'Absolute paths are not allowed.') };
118
125
  }
119
126
  const normalized = normalize(value);
120
127
  if (normalized.startsWith('..') || normalized.includes('/../')) {
121
- return { ok: false, reason: 'path traversal is not allowed' };
128
+ return { ok: false, reason: evidencePathReason(fieldName, 'Path traversal is not allowed.') };
122
129
  }
123
130
  if (!projectPath) {
124
131
  return { ok: true, path: normalized };
125
132
  }
126
133
  return { ok: true, path: join(projectPath, normalized) };
127
134
  }
135
+ function evidencePathReason(fieldName, detail) {
136
+ if (fieldName === 'testEvidence') {
137
+ return `testEvidence must contain existing file paths only; put command output in validationEvidence. ${detail}`;
138
+ }
139
+ if (fieldName === 'changedFiles') {
140
+ return `changedFiles must contain existing repository-relative paths only; no globs or commands. ${detail}`;
141
+ }
142
+ return `Evidence path must be an existing repository-relative path. ${detail}`;
143
+ }
144
+ function looksLikeShellCommand(value) {
145
+ const trimmed = value.trim();
146
+ if (/\s/.test(trimmed) && !/\.[A-Za-z0-9]+$/.test(trimmed)) {
147
+ return true;
148
+ }
149
+ return /^(?:pnpm|npm|yarn|bun|npx|node|tsx|tsc|vitest|jest|git|bash|sh)\b/.test(trimmed);
150
+ }
151
+ function looksLikeGlob(value) {
152
+ return /[*?[\]{}]/.test(value);
153
+ }
128
154
  function record(kind, value, status, source, reason) {
129
155
  return { kind, value, status, source, ...(reason ? { reason } : {}) };
130
156
  }
@@ -1,5 +1,9 @@
1
- import type { RemoteFeedbackPayload } from '../types/feedback.js';
1
+ import type { FeedbackRemoteResult, RemoteFeedbackPayload } from '../types/feedback.js';
2
+ export declare const DEFAULT_FEEDBACK_API = "https://canusqigtzldstnjddio.supabase.co/functions/v1/license-api/feedback";
2
3
  export type { RemoteFeedbackPayload };
4
+ export declare function resolveFeedbackEndpoint(endpoint?: string): string;
5
+ export declare function sanitizeRemoteFeedbackPayload(payload: RemoteFeedbackPayload): RemoteFeedbackPayload;
6
+ export declare function deliverFeedbackRemote(payload: RemoteFeedbackPayload, endpoint?: string): Promise<FeedbackRemoteResult>;
3
7
  /**
4
8
  * Send feedback to central Supabase store (fire-and-forget).
5
9
  * Never throws. Returns true if sent successfully, false otherwise.
@@ -1,27 +1,86 @@
1
1
  // storage/feedback-remote.ts — Remote feedback relay to Supabase (fire-and-forget)
2
2
  // No PII is ever sent: no project paths, no user names, no emails.
3
- const FEEDBACK_API = 'https://canusqigtzldstnjddio.supabase.co/functions/v1/license-api/feedback';
4
- /**
5
- * Send feedback to central Supabase store (fire-and-forget).
6
- * Never throws. Returns true if sent successfully, false otherwise.
7
- */
8
- export async function sendFeedbackRemote(payload) {
3
+ export const DEFAULT_FEEDBACK_API = 'https://canusqigtzldstnjddio.supabase.co/functions/v1/license-api/feedback';
4
+ export function resolveFeedbackEndpoint(endpoint) {
5
+ return endpoint ?? process.env.PLANU_FEEDBACK_ENDPOINT ?? DEFAULT_FEEDBACK_API;
6
+ }
7
+ function redactSensitiveText(value) {
8
+ return value
9
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted-email]')
10
+ .replace(/(?:\/Users|\/home|\/var|\/tmp|[A-Za-z]:\\)[^\s)'"`]+/g, '[redacted-path]')
11
+ .replace(/```[\s\S]*?```/g, '[redacted-code-block]')
12
+ .replace(/\b(?:const|let|var|function|class|import|export)\s+[^\n]+/g, '[redacted-code]');
13
+ }
14
+ export function sanitizeRemoteFeedbackPayload(payload) {
15
+ return {
16
+ ...payload,
17
+ title: redactSensitiveText(payload.title),
18
+ description: redactSensitiveText(payload.description),
19
+ ...(payload.stack !== undefined ? { stack: redactSensitiveText(payload.stack) } : {}),
20
+ };
21
+ }
22
+ export async function deliverFeedbackRemote(payload, endpoint) {
23
+ const resolvedEndpoint = resolveFeedbackEndpoint(endpoint);
24
+ let url;
9
25
  try {
10
- const controller = new AbortController();
11
- const timeout = setTimeout(() => {
12
- controller.abort();
13
- }, 5000); // 5s timeout
14
- const res = await fetch(FEEDBACK_API, {
26
+ url = new URL(resolvedEndpoint);
27
+ }
28
+ catch {
29
+ return {
30
+ ok: false,
31
+ status: 'skipped',
32
+ endpoint: resolvedEndpoint,
33
+ error: 'Invalid feedback endpoint URL.',
34
+ };
35
+ }
36
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
37
+ return {
38
+ ok: false,
39
+ status: 'skipped',
40
+ endpoint: resolvedEndpoint,
41
+ error: 'Feedback endpoint must use HTTPS.',
42
+ };
43
+ }
44
+ const controller = new AbortController();
45
+ const timeout = setTimeout(() => {
46
+ controller.abort();
47
+ }, 5000);
48
+ try {
49
+ const res = await fetch(resolvedEndpoint, {
15
50
  method: 'POST',
16
51
  headers: { 'Content-Type': 'application/json' },
17
- body: JSON.stringify(payload),
52
+ body: JSON.stringify(sanitizeRemoteFeedbackPayload(payload)),
18
53
  signal: controller.signal,
19
54
  });
20
- clearTimeout(timeout);
21
- return res.ok;
55
+ if (res.ok) {
56
+ return { ok: true, status: 'synced', endpoint: resolvedEndpoint };
57
+ }
58
+ return {
59
+ ok: false,
60
+ status: 'failed',
61
+ endpoint: resolvedEndpoint,
62
+ error: `Remote feedback endpoint returned HTTP ${res.status}.`,
63
+ };
22
64
  }
23
- catch {
24
- return false; // Network error, timeout, etc. silently fail
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ return {
68
+ ok: false,
69
+ status: 'failed',
70
+ endpoint: resolvedEndpoint,
71
+ error: message || 'Remote feedback request failed.',
72
+ };
25
73
  }
74
+ finally {
75
+ clearTimeout(timeout);
76
+ }
77
+ }
78
+ /**
79
+ * Send feedback to central Supabase store (fire-and-forget).
80
+ * Never throws. Returns true if sent successfully, false otherwise.
81
+ */
82
+ export async function sendFeedbackRemote(payload) {
83
+ const result = await deliverFeedbackRemote(payload);
84
+ return result.ok;
26
85
  }
27
86
  //# sourceMappingURL=feedback-remote.js.map
@@ -1,4 +1,4 @@
1
- import type { FeedbackEntry, FeedbackFilter, FeedbackSummary } from '../types/index.js';
1
+ import type { FeedbackEntry, FeedbackFilter, FeedbackSummary, FeedbackRemoteHealth, SyncFeedbackInput, SyncFeedbackSummary } from '../types/index.js';
2
2
  /**
3
3
  * Submit a new feedback entry. Auto-generates ID, timestamp, and version.
4
4
  */
@@ -19,6 +19,8 @@ export declare function resolveFeedback(dataDir: string, id: string, specId?: st
19
19
  * Compute summary statistics across all feedback.
20
20
  */
21
21
  export declare function getSummary(dataDir: string): FeedbackSummary;
22
+ export declare function getFeedbackRemoteHealth(dataDir: string): FeedbackRemoteHealth;
23
+ export declare function syncFeedbackRemote(dataDir: string, input?: SyncFeedbackInput): Promise<SyncFeedbackSummary>;
22
24
  /**
23
25
  * Find feedback entries with title similar to the given string.
24
26
  * Uses Jaccard word overlap. Threshold defaults to 0.3.
@@ -2,7 +2,7 @@
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { withFileLock } from './file-mutex.js';
5
- import { sendFeedbackRemote } from './feedback-remote.js';
5
+ import { deliverFeedbackRemote } from './feedback-remote.js';
6
6
  // ---------------------------------------------------------------------------
7
7
  // Path helpers
8
8
  // ---------------------------------------------------------------------------
@@ -37,6 +37,54 @@ function writeLedger(filePath, entries) {
37
37
  ensureDir(dirname(filePath));
38
38
  writeFileSync(filePath, JSON.stringify(entries, null, 2), 'utf-8');
39
39
  }
40
+ function getRemoteStatus(entry) {
41
+ return entry.remoteDelivery?.status ?? 'pending';
42
+ }
43
+ function toRemotePayload(entry) {
44
+ return {
45
+ id: entry.id,
46
+ type: entry.type,
47
+ title: entry.title,
48
+ description: entry.description,
49
+ planuVersion: entry.planuVersion,
50
+ stack: entry.context?.stack,
51
+ tags: entry.tags,
52
+ };
53
+ }
54
+ function toRemoteDelivery(entry, result) {
55
+ const now = new Date().toISOString();
56
+ const previousAttempts = entry.remoteDelivery?.attemptCount ?? 0;
57
+ return {
58
+ status: result.status,
59
+ attemptCount: previousAttempts + 1,
60
+ lastAttemptAt: now,
61
+ ...(result.ok ? { lastSyncedAt: now } : {}),
62
+ ...(result.error !== undefined ? { lastError: result.error.slice(0, 300) } : {}),
63
+ ...(result.endpoint !== undefined ? { endpoint: result.endpoint } : {}),
64
+ };
65
+ }
66
+ async function updateRemoteDelivery(dataDir, id, result) {
67
+ const filePath = getFeedbackPath(dataDir);
68
+ return withFileLock(filePath, () => {
69
+ const entries = readLedger(filePath);
70
+ const idx = entries.findIndex((e) => e.id === id);
71
+ const existing = entries[idx];
72
+ if (idx === -1 || existing === undefined) {
73
+ return Promise.resolve(null);
74
+ }
75
+ const updated = {
76
+ ...existing,
77
+ remoteDelivery: toRemoteDelivery(existing, result),
78
+ };
79
+ entries[idx] = updated;
80
+ writeLedger(filePath, entries);
81
+ return Promise.resolve(updated);
82
+ });
83
+ }
84
+ async function deliverAndPersistRemote(dataDir, entry) {
85
+ const result = await deliverFeedbackRemote(toRemotePayload(entry));
86
+ return (await updateRemoteDelivery(dataDir, entry.id, result)) ?? entry;
87
+ }
40
88
  // ---------------------------------------------------------------------------
41
89
  // ID generation
42
90
  // ---------------------------------------------------------------------------
@@ -144,18 +192,12 @@ export async function submitFeedback(dataDir, entry) {
144
192
  id,
145
193
  createdAt: new Date().toISOString(),
146
194
  planuVersion: version,
195
+ remoteDelivery: { status: 'pending', attemptCount: 0 },
147
196
  };
148
197
  entries.push(newEntry);
149
198
  writeLedger(filePath, entries);
150
- void sendFeedbackRemote({
151
- type: newEntry.type,
152
- title: newEntry.title,
153
- description: newEntry.description,
154
- planuVersion: newEntry.planuVersion,
155
- stack: newEntry.context?.stack,
156
- tags: newEntry.tags,
157
- }).catch((err) => {
158
- console.error('[planu] sendFeedbackRemote failed:', err);
199
+ void deliverAndPersistRemote(dataDir, newEntry).catch((err) => {
200
+ console.error('[planu] feedback remote delivery failed:', err);
159
201
  });
160
202
  return Promise.resolve(newEntry);
161
203
  });
@@ -259,6 +301,61 @@ export function getSummary(dataDir) {
259
301
  topRequests,
260
302
  };
261
303
  }
304
+ export function getFeedbackRemoteHealth(dataDir) {
305
+ const entries = readLedger(getFeedbackPath(dataDir));
306
+ const byRemoteStatus = {
307
+ pending: 0,
308
+ synced: 0,
309
+ failed: 0,
310
+ skipped: 0,
311
+ };
312
+ for (const entry of entries) {
313
+ byRemoteStatus[getRemoteStatus(entry)]++;
314
+ }
315
+ return {
316
+ byRemoteStatus,
317
+ retryable: byRemoteStatus.pending + byRemoteStatus.failed,
318
+ };
319
+ }
320
+ export async function syncFeedbackRemote(dataDir, input = {}) {
321
+ const limit = input.limit !== undefined && input.limit > 0 ? input.limit : Number.POSITIVE_INFINITY;
322
+ const entries = readLedger(getFeedbackPath(dataDir));
323
+ const summary = {
324
+ total: 0,
325
+ synced: 0,
326
+ failed: 0,
327
+ skipped: 0,
328
+ alreadySynced: 0,
329
+ processedIds: [],
330
+ };
331
+ for (const entry of entries) {
332
+ if (summary.total >= limit) {
333
+ break;
334
+ }
335
+ const status = getRemoteStatus(entry);
336
+ if (status === 'synced' && input.includeSynced !== true) {
337
+ summary.alreadySynced++;
338
+ continue;
339
+ }
340
+ if (!['pending', 'failed', 'skipped', 'synced'].includes(status)) {
341
+ continue;
342
+ }
343
+ summary.total++;
344
+ summary.processedIds.push(entry.id);
345
+ const updated = await deliverAndPersistRemote(dataDir, entry);
346
+ const updatedStatus = getRemoteStatus(updated);
347
+ if (updatedStatus === 'synced') {
348
+ summary.synced++;
349
+ }
350
+ else if (updatedStatus === 'skipped') {
351
+ summary.skipped++;
352
+ }
353
+ else {
354
+ summary.failed++;
355
+ }
356
+ }
357
+ return summary;
358
+ }
262
359
  /**
263
360
  * Find feedback entries with title similar to the given string.
264
361
  * Uses Jaccard word overlap. Threshold defaults to 0.3.
@@ -1,5 +1,7 @@
1
- import type { ToolResult, SubmitFeedbackInput, TriageFeedbackInput, ResolveFeedbackInput } from '../types/index.js';
1
+ import type { ToolResult, SubmitFeedbackInput, TriageFeedbackInput, ResolveFeedbackInput, SyncFeedbackInput, FeedbackStatusInput } from '../types/index.js';
2
2
  export declare function handleSubmitFeedback(input: SubmitFeedbackInput): Promise<ToolResult>;
3
3
  export declare function handleTriageFeedback(input: TriageFeedbackInput): ToolResult;
4
4
  export declare function handleResolveFeedback(input: ResolveFeedbackInput): Promise<ToolResult>;
5
+ export declare function handleSyncFeedback(input: SyncFeedbackInput): Promise<ToolResult>;
6
+ export declare function handleFeedbackStatus(input: FeedbackStatusInput): ToolResult;
5
7
  //# sourceMappingURL=feedback-handler.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // tools/feedback-handler.ts — Feedback Hub tool handlers (SPEC-188)
2
2
  import { join } from 'node:path';
3
- import { submitFeedback, getFeedback, resolveFeedback, findSimilar, } from '../storage/feedback-store.js';
3
+ import { submitFeedback, getFeedback, resolveFeedback, findSimilar, getSummary, getFeedbackRemoteHealth, syncFeedbackRemote, } from '../storage/feedback-store.js';
4
4
  import { triageFeedback, formatTriageReport } from '../engine/feedback/triage.js';
5
5
  // ---------------------------------------------------------------------------
6
6
  // Default data directory
@@ -43,6 +43,7 @@ export async function handleSubmitFeedback(input) {
43
43
  `| Type | ${TYPE_EMOJI[feedbackType]} |`,
44
44
  `| Title | ${entry.title} |`,
45
45
  `| Status | Pending |`,
46
+ `| Remote Delivery | ${entry.remoteDelivery?.status ?? 'pending'} |`,
46
47
  ];
47
48
  if (topSimilar !== undefined) {
48
49
  lines.push('');
@@ -103,4 +104,49 @@ export async function handleResolveFeedback(input) {
103
104
  }
104
105
  return { content: [{ type: 'text', text: lines.join('\n') }] };
105
106
  }
107
+ // ---------------------------------------------------------------------------
108
+ // sync_feedback handler
109
+ // ---------------------------------------------------------------------------
110
+ export async function handleSyncFeedback(input) {
111
+ const dataDir = getDataDir(input.projectPath);
112
+ const summary = await syncFeedbackRemote(dataDir, input);
113
+ const lines = [
114
+ 'Feedback remote sync complete.',
115
+ '',
116
+ '| Field | Value |',
117
+ '|-------|-------|',
118
+ `| Processed | ${summary.total} |`,
119
+ `| Synced | ${summary.synced} |`,
120
+ `| Failed | ${summary.failed} |`,
121
+ `| Skipped | ${summary.skipped} |`,
122
+ `| Already Synced | ${summary.alreadySynced} |`,
123
+ ];
124
+ if (summary.processedIds.length > 0) {
125
+ lines.push(`| Processed IDs | ${summary.processedIds.join(', ')} |`);
126
+ }
127
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // feedback_status handler
131
+ // ---------------------------------------------------------------------------
132
+ export function handleFeedbackStatus(input) {
133
+ const dataDir = getDataDir(input.projectPath);
134
+ const summary = getSummary(dataDir);
135
+ const remote = getFeedbackRemoteHealth(dataDir);
136
+ const lines = [
137
+ 'Feedback status',
138
+ '',
139
+ '| Field | Value |',
140
+ '|-------|-------|',
141
+ `| Total | ${summary.total} |`,
142
+ `| Pending Local | ${summary.pending} |`,
143
+ `| Resolved Local | ${summary.resolved} |`,
144
+ `| Remote Pending | ${remote.byRemoteStatus.pending} |`,
145
+ `| Remote Synced | ${remote.byRemoteStatus.synced} |`,
146
+ `| Remote Failed | ${remote.byRemoteStatus.failed} |`,
147
+ `| Remote Skipped | ${remote.byRemoteStatus.skipped} |`,
148
+ `| Retryable | ${remote.retryable} |`,
149
+ ];
150
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
151
+ }
106
152
  //# sourceMappingURL=feedback-handler.js.map
@@ -1,4 +1,4 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare const OFFICIAL_SDD_TOOL_NAMES: readonly ["planu_status", "facilitate", "init_project", "clarify_requirements", "create_spec", "challenge_spec", "check_readiness", "update_status", "update_status_batch", "package_handoff", "validate", "reconcile_spec", "create_rule", "create_skill", "skill_search", "configure_code_graph", "code_graph_status"];
2
+ export declare const OFFICIAL_SDD_TOOL_NAMES: readonly ["planu_status", "facilitate", "init_project", "clarify_requirements", "create_spec", "challenge_spec", "check_readiness", "update_status", "update_status_batch", "package_handoff", "validate", "reconcile_spec", "create_rule", "create_skill", "skill_search", "configure_code_graph", "code_graph_status", "submit_feedback", "triage_feedback", "resolve_feedback", "sync_feedback", "feedback_status"];
3
3
  export declare function registerSddTools(server: McpServer): void;
4
4
  //# sourceMappingURL=register-sdd-tools.d.ts.map
@@ -30,6 +30,11 @@ export const OFFICIAL_SDD_TOOL_NAMES = [
30
30
  'skill_search',
31
31
  'configure_code_graph',
32
32
  'code_graph_status',
33
+ 'submit_feedback',
34
+ 'triage_feedback',
35
+ 'resolve_feedback',
36
+ 'sync_feedback',
37
+ 'feedback_status',
33
38
  ];
34
39
  const OFFICIAL_SDD_TOOL_SET = new Set(OFFICIAL_SDD_TOOL_NAMES);
35
40
  const noop = () => undefined;
@@ -27,19 +27,13 @@ const execFile = promisify(execFileCb);
27
27
  // ---------------------------------------------------------------------------
28
28
  async function getGitState(projectPath) {
29
29
  try {
30
- const [statusResult, branchResult] = await Promise.all([
31
- execFile('git', ['status', '--short'], { cwd: projectPath }),
30
+ const [stagedResult, modifiedResult, branchResult] = await Promise.all([
31
+ execFile('git', ['diff', '--cached', '--name-only'], { cwd: projectPath }),
32
+ execFile('git', ['diff', '--name-only'], { cwd: projectPath }),
32
33
  execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }),
33
34
  ]);
34
- const lines = statusResult.stdout.trim().split('\n').filter(Boolean);
35
- const staged = lines.filter((l) => {
36
- const ch = l[0];
37
- return ch !== ' ' && ch !== '?';
38
- }).length;
39
- const modified = lines.filter((l) => {
40
- const ch = l[1];
41
- return ch !== ' ' && ch !== '?';
42
- }).length;
35
+ const staged = countGitPathLines(stagedResult.stdout);
36
+ const modified = countGitPathLines(modifiedResult.stdout);
43
37
  const branch = branchResult.stdout.trim();
44
38
  return { staged, modified, branch };
45
39
  }
@@ -47,6 +41,12 @@ async function getGitState(projectPath) {
47
41
  return null;
48
42
  }
49
43
  }
44
+ function countGitPathLines(stdout) {
45
+ return stdout
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .filter(Boolean).length;
49
+ }
50
50
  // ---------------------------------------------------------------------------
51
51
  // CI state helpers
52
52
  // ---------------------------------------------------------------------------