gsdd-cli 0.21.0 → 0.23.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/README.md +3 -1
- package/agents/executor.md +12 -1
- package/agents/planner.md +2 -0
- package/agents/verifier.md +1 -1
- package/bin/gsdd.mjs +3 -2
- package/bin/lib/control-map.mjs +629 -0
- package/bin/lib/health.mjs +2 -2
- package/bin/lib/init-flow.mjs +7 -7
- package/bin/lib/init-runtime.mjs +3 -0
- package/bin/lib/rendering.mjs +5 -0
- package/bin/lib/ui-proof.mjs +136 -13
- package/distilled/DESIGN.md +63 -7
- package/distilled/EVIDENCE-INDEX.md +23 -0
- package/distilled/README.md +9 -0
- package/distilled/templates/delegates/plan-checker.md +1 -0
- package/distilled/templates/ui-proof.md +33 -7
- package/distilled/workflows/execute.md +5 -4
- package/distilled/workflows/pause.md +2 -0
- package/distilled/workflows/plan.md +7 -6
- package/distilled/workflows/progress.md +4 -0
- package/distilled/workflows/quick.md +6 -3
- package/distilled/workflows/resume.md +4 -0
- package/distilled/workflows/verify.md +1 -0
- package/docs/USER-GUIDE.md +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// control-map.mjs - computed-first workspace/worktree control map
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'path';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import { output, parseFlagValue } from './cli-utils.mjs';
|
|
7
|
+
import { evaluateLifecycleState } from './lifecycle-state.mjs';
|
|
8
|
+
import { checkDrift } from './session-fingerprint.mjs';
|
|
9
|
+
import { resolveWorkspaceContext } from './workspace-root.mjs';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ANNOTATIONS_RELATIVE_PATH = '.planning/.local/control-map.annotations.json';
|
|
12
|
+
const MAX_DIRTY_BUCKET_ENTRIES = 200;
|
|
13
|
+
const CLEANUP_STATES = Object.freeze(['active', 'paused', 'merged', 'abandoned', 'superseded', 'cleanup_deferred']);
|
|
14
|
+
const AUTHORITY_ORDER = Object.freeze([
|
|
15
|
+
'repo_truth',
|
|
16
|
+
'planning_artifacts',
|
|
17
|
+
'checkpoint_narrative',
|
|
18
|
+
'local_annotations',
|
|
19
|
+
'vendor_session_forensics',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function runGit(cwd, args) {
|
|
23
|
+
const command = `git ${args.join(' ')}`;
|
|
24
|
+
try {
|
|
25
|
+
const result = spawnSync('git', args, {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
29
|
+
timeout: 10000,
|
|
30
|
+
});
|
|
31
|
+
const errorMessage = result.error ? result.error.message : '';
|
|
32
|
+
return {
|
|
33
|
+
ok: result.status === 0 && !result.error,
|
|
34
|
+
status: result.status ?? (result.error ? -1 : null),
|
|
35
|
+
stdout: String(result.stdout || '').trimEnd(),
|
|
36
|
+
stderr: String(result.stderr || errorMessage || '').trimEnd(),
|
|
37
|
+
command,
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
status: -1,
|
|
43
|
+
stdout: '',
|
|
44
|
+
stderr: error.message,
|
|
45
|
+
command,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runIgnoredCount(cwd) {
|
|
51
|
+
const result = runGit(cwd, ['ls-files', '-o', '-i', '--exclude-standard']);
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
count: 0,
|
|
56
|
+
error: result.stderr || result.stdout || 'git ls-files ignored count failed',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const count = result.stdout ? result.stdout.split('\n').filter(Boolean).length : 0;
|
|
61
|
+
return { ok: true, count };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeSlashes(value) {
|
|
65
|
+
return String(value || '').replace(/\\/g, '/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function workspacePathLabel(workspaceRoot, targetPath) {
|
|
69
|
+
const absolute = resolve(targetPath);
|
|
70
|
+
const rel = relative(workspaceRoot, absolute);
|
|
71
|
+
if (rel === '') return '.';
|
|
72
|
+
if (!rel.startsWith('..') && !isAbsolute(rel)) return normalizeSlashes(rel);
|
|
73
|
+
return normalizeSlashes(absolute);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isInsideOrSame(parentPath, targetPath) {
|
|
77
|
+
const rel = relative(resolve(parentPath), resolve(targetPath));
|
|
78
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeReadJson(filePath) {
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, value: JSON.parse(readFileSync(filePath, 'utf-8')) };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, error: error.message };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseStatusBuckets(statusOutput) {
|
|
90
|
+
const buckets = {
|
|
91
|
+
branch_line: null,
|
|
92
|
+
tracked: [],
|
|
93
|
+
untracked: [],
|
|
94
|
+
ignored: [],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
for (const rawLine of String(statusOutput || '').split('\n')) {
|
|
98
|
+
const line = rawLine.trimEnd();
|
|
99
|
+
if (!line) continue;
|
|
100
|
+
if (line.startsWith('## ')) {
|
|
101
|
+
buckets.branch_line = line.slice(3);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const code = line.slice(0, 2);
|
|
105
|
+
const file = line.slice(3);
|
|
106
|
+
if (code === '??') buckets.untracked.push(file);
|
|
107
|
+
else if (code === '!!') buckets.ignored.push(file);
|
|
108
|
+
else buckets.tracked.push(line);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return buckets;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function capBucket(entries) {
|
|
115
|
+
return entries.length > MAX_DIRTY_BUCKET_ENTRIES ? entries.slice(0, MAX_DIRTY_BUCKET_ENTRIES) : entries;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseAheadBehind(branchLine) {
|
|
119
|
+
if (!branchLine) return { ahead: null, behind: null };
|
|
120
|
+
if (!branchLine.includes('...')) return { ahead: null, behind: null };
|
|
121
|
+
const ahead = branchLine.match(/ahead (\d+)/)?.[1];
|
|
122
|
+
const behind = branchLine.match(/behind (\d+)/)?.[1];
|
|
123
|
+
return {
|
|
124
|
+
ahead: ahead === undefined ? 0 : Number(ahead),
|
|
125
|
+
behind: behind === undefined ? 0 : Number(behind),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readGitWorktree(pathValue, workspaceRoot, { includeIgnoredPaths = false, includeIgnoredCount = false } = {}) {
|
|
130
|
+
const status = runGit(pathValue, [
|
|
131
|
+
'status',
|
|
132
|
+
'--short',
|
|
133
|
+
'--branch',
|
|
134
|
+
...(includeIgnoredPaths ? ['--ignored'] : []),
|
|
135
|
+
'--untracked-files=all',
|
|
136
|
+
]);
|
|
137
|
+
const branch = runGit(pathValue, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
138
|
+
const head = runGit(pathValue, ['rev-parse', 'HEAD']);
|
|
139
|
+
const gitTopLevel = runGit(pathValue, ['rev-parse', '--show-toplevel']);
|
|
140
|
+
const buckets = status.ok ? parseStatusBuckets(status.stdout) : parseStatusBuckets('');
|
|
141
|
+
const ignoredCountResult = includeIgnoredCount && !includeIgnoredPaths ? runIgnoredCount(pathValue) : null;
|
|
142
|
+
const ignoredEntries = includeIgnoredPaths ? capBucket(buckets.ignored) : [];
|
|
143
|
+
const ignoredCount = includeIgnoredPaths
|
|
144
|
+
? buckets.ignored.length
|
|
145
|
+
: ignoredCountResult && ignoredCountResult.ok
|
|
146
|
+
? ignoredCountResult.count
|
|
147
|
+
: null;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
id: workspacePathLabel(workspaceRoot, pathValue),
|
|
151
|
+
path: normalizeSlashes(resolve(pathValue)),
|
|
152
|
+
path_relative: workspacePathLabel(workspaceRoot, pathValue),
|
|
153
|
+
path_inside_workspace: isInsideOrSame(workspaceRoot, pathValue),
|
|
154
|
+
git_valid: status.ok && branch.ok && head.ok,
|
|
155
|
+
branch: branch.ok ? branch.stdout : null,
|
|
156
|
+
head: head.ok ? head.stdout : null,
|
|
157
|
+
git_top_level: gitTopLevel.ok ? normalizeSlashes(resolve(gitTopLevel.stdout)) : null,
|
|
158
|
+
dirty: {
|
|
159
|
+
tracked: capBucket(buckets.tracked),
|
|
160
|
+
untracked: capBucket(buckets.untracked),
|
|
161
|
+
ignored: ignoredEntries,
|
|
162
|
+
counts: {
|
|
163
|
+
tracked: buckets.tracked.length,
|
|
164
|
+
untracked: buckets.untracked.length,
|
|
165
|
+
ignored: ignoredCount,
|
|
166
|
+
},
|
|
167
|
+
omitted_counts: {
|
|
168
|
+
tracked: Math.max(0, buckets.tracked.length - MAX_DIRTY_BUCKET_ENTRIES),
|
|
169
|
+
untracked: Math.max(0, buckets.untracked.length - MAX_DIRTY_BUCKET_ENTRIES),
|
|
170
|
+
ignored: includeIgnoredPaths ? Math.max(0, buckets.ignored.length - MAX_DIRTY_BUCKET_ENTRIES) : null,
|
|
171
|
+
},
|
|
172
|
+
ignored_paths_included: includeIgnoredPaths,
|
|
173
|
+
ignored_count_included: includeIgnoredPaths || Boolean(ignoredCountResult),
|
|
174
|
+
ignored_count_error: ignoredCountResult && !ignoredCountResult.ok ? ignoredCountResult.error : null,
|
|
175
|
+
},
|
|
176
|
+
ahead_behind: parseAheadBehind(buckets.branch_line),
|
|
177
|
+
status_error: status.ok ? null : [status.stderr, status.stdout].filter(Boolean).join('\n') || 'git status failed',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseWorktreeList(outputText) {
|
|
182
|
+
const entries = [];
|
|
183
|
+
let current = null;
|
|
184
|
+
|
|
185
|
+
for (const line of String(outputText || '').split('\n')) {
|
|
186
|
+
if (!line.trim()) {
|
|
187
|
+
if (current) entries.push(current);
|
|
188
|
+
current = null;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const [key, ...rest] = line.split(' ');
|
|
192
|
+
const value = rest.join(' ');
|
|
193
|
+
if (key === 'worktree') {
|
|
194
|
+
if (current) entries.push(current);
|
|
195
|
+
current = { path: value, detached: false, bare: false };
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!current) continue;
|
|
199
|
+
if (key === 'HEAD') current.head = value;
|
|
200
|
+
else if (key === 'branch') current.branch_ref = value;
|
|
201
|
+
else if (key === 'detached') current.detached = true;
|
|
202
|
+
else if (key === 'bare') current.bare = true;
|
|
203
|
+
}
|
|
204
|
+
if (current) entries.push(current);
|
|
205
|
+
return entries;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function discoverGitWorktrees(workspaceRoot, { includeIgnoredPaths = false, includeIgnoredCount = false } = {}) {
|
|
209
|
+
const listed = runGit(workspaceRoot, ['worktree', 'list', '--porcelain']);
|
|
210
|
+
if (!listed.ok) {
|
|
211
|
+
return {
|
|
212
|
+
git_available: false,
|
|
213
|
+
worktrees: [readGitWorktree(workspaceRoot, workspaceRoot, { includeIgnoredPaths, includeIgnoredCount: includeIgnoredCount || includeIgnoredPaths })],
|
|
214
|
+
errors: [{ code: 'git_worktree_list_failed', message: listed.stderr || listed.stdout || 'git worktree list failed' }],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const rawEntries = parseWorktreeList(listed.stdout);
|
|
219
|
+
const paths = rawEntries.length > 0 ? rawEntries.map((entry) => entry.path) : [workspaceRoot];
|
|
220
|
+
const byPath = new Map(rawEntries.map((entry) => [resolve(entry.path), entry]));
|
|
221
|
+
return {
|
|
222
|
+
git_available: true,
|
|
223
|
+
worktrees: paths.map((pathValue) => {
|
|
224
|
+
const isCanonical = resolve(pathValue) === resolve(workspaceRoot);
|
|
225
|
+
const computed = readGitWorktree(pathValue, workspaceRoot, {
|
|
226
|
+
includeIgnoredPaths,
|
|
227
|
+
includeIgnoredCount: includeIgnoredPaths || (isCanonical && includeIgnoredCount),
|
|
228
|
+
});
|
|
229
|
+
const raw = byPath.get(resolve(pathValue)) || {};
|
|
230
|
+
return {
|
|
231
|
+
...computed,
|
|
232
|
+
branch_ref: raw.branch_ref || null,
|
|
233
|
+
detached: raw.detached === true || computed.branch === 'HEAD',
|
|
234
|
+
bare: raw.bare === true,
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
errors: [],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function discoverRuntimeDirs(workspaceRoot) {
|
|
242
|
+
const candidates = [
|
|
243
|
+
{ runtime: 'claude-code', rel: '.claude/worktrees' },
|
|
244
|
+
{ runtime: 'codex-cli', rel: '.codex/worktrees' },
|
|
245
|
+
{ runtime: 'opencode', rel: '.opencode/worktrees' },
|
|
246
|
+
];
|
|
247
|
+
const dirs = [];
|
|
248
|
+
for (const candidate of candidates) {
|
|
249
|
+
const root = join(workspaceRoot, candidate.rel);
|
|
250
|
+
if (!existsSync(root)) continue;
|
|
251
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
252
|
+
if (!entry.isDirectory()) continue;
|
|
253
|
+
const fullPath = join(root, entry.name);
|
|
254
|
+
const dotGit = join(fullPath, '.git');
|
|
255
|
+
dirs.push({
|
|
256
|
+
runtime: candidate.runtime,
|
|
257
|
+
path: normalizeSlashes(fullPath),
|
|
258
|
+
path_relative: normalizeSlashes(join(candidate.rel, entry.name)),
|
|
259
|
+
git_marker_exists: existsSync(dotGit),
|
|
260
|
+
appears_git_worktree: existsSync(dotGit),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return dirs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeAnnotationPath(workspaceRoot, annotation) {
|
|
268
|
+
const raw = annotation.path || annotation.worktree_path || annotation.path_relative;
|
|
269
|
+
if (!raw) return null;
|
|
270
|
+
return normalizeSlashes(resolve(workspaceRoot, raw));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
|
|
274
|
+
const annotationPath = annotationPathArg
|
|
275
|
+
? resolve(workspaceRoot, annotationPathArg)
|
|
276
|
+
: join(planningDir, '.local', 'control-map.annotations.json');
|
|
277
|
+
if (!isInsideOrSame(workspaceRoot, annotationPath)) {
|
|
278
|
+
return {
|
|
279
|
+
path: workspacePathLabel(workspaceRoot, annotationPath),
|
|
280
|
+
exists: false,
|
|
281
|
+
valid: false,
|
|
282
|
+
errors: [{
|
|
283
|
+
code: 'annotations_path_outside_workspace',
|
|
284
|
+
path: workspacePathLabel(workspaceRoot, annotationPath),
|
|
285
|
+
message: 'Control-map annotations path must stay inside the workspace.',
|
|
286
|
+
}],
|
|
287
|
+
worktrees: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (!existsSync(annotationPath)) {
|
|
291
|
+
return {
|
|
292
|
+
path: workspacePathLabel(workspaceRoot, annotationPath),
|
|
293
|
+
exists: false,
|
|
294
|
+
valid: true,
|
|
295
|
+
errors: [],
|
|
296
|
+
worktrees: [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const parsed = safeReadJson(annotationPath);
|
|
301
|
+
if (!parsed.ok) {
|
|
302
|
+
return {
|
|
303
|
+
path: workspacePathLabel(workspaceRoot, annotationPath),
|
|
304
|
+
exists: true,
|
|
305
|
+
valid: false,
|
|
306
|
+
errors: [{ code: 'invalid_annotations_json', path: workspacePathLabel(workspaceRoot, annotationPath), message: parsed.error }],
|
|
307
|
+
worktrees: [],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const rawWorktrees = Array.isArray(parsed.value?.worktrees)
|
|
312
|
+
? parsed.value.worktrees
|
|
313
|
+
: Array.isArray(parsed.value?.annotations)
|
|
314
|
+
? parsed.value.annotations
|
|
315
|
+
: [];
|
|
316
|
+
const errors = [];
|
|
317
|
+
if (!parsed.value || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) {
|
|
318
|
+
errors.push({ code: 'invalid_annotations_shape', path: workspacePathLabel(workspaceRoot, annotationPath), message: 'Control-map annotations must be a JSON object.' });
|
|
319
|
+
}
|
|
320
|
+
if (rawWorktrees.length === 0 && parsed.value?.worktrees !== undefined && !Array.isArray(parsed.value.worktrees)) {
|
|
321
|
+
errors.push({ code: 'invalid_worktrees_annotations', path: `${workspacePathLabel(workspaceRoot, annotationPath)}.worktrees`, message: 'worktrees must be an array.' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const worktrees = rawWorktrees.filter((entry, index) => {
|
|
325
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) return true;
|
|
326
|
+
errors.push({ code: 'invalid_annotation', path: `worktrees[${index}]`, message: 'Annotation entries must be objects.' });
|
|
327
|
+
return false;
|
|
328
|
+
}).map((entry, index) => ({
|
|
329
|
+
id: entry.id || `annotation-${index + 1}`,
|
|
330
|
+
runtime_owner: entry.runtime_owner || entry.runtime || null,
|
|
331
|
+
path: entry.path || entry.worktree_path || null,
|
|
332
|
+
path_relative: entry.path_relative || null,
|
|
333
|
+
normalized_path: normalizeAnnotationPath(workspaceRoot, entry),
|
|
334
|
+
branch: entry.branch || null,
|
|
335
|
+
last_known_head: entry.last_known_head || entry.head || null,
|
|
336
|
+
intended_scope: entry.intended_scope || entry.scope || null,
|
|
337
|
+
write_set: Array.isArray(entry.write_set) ? entry.write_set : [],
|
|
338
|
+
cleanup_state: entry.cleanup_state || 'active',
|
|
339
|
+
proof_state: entry.proof_state || null,
|
|
340
|
+
next_step: entry.next_step || null,
|
|
341
|
+
updated_at: entry.updated_at || null,
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
for (const [index, entry] of worktrees.entries()) {
|
|
345
|
+
if (!entry.normalized_path) errors.push({ code: 'missing_annotation_path', path: `worktrees[${index}].path`, message: 'Annotation must include path or path_relative.' });
|
|
346
|
+
if (!CLEANUP_STATES.includes(entry.cleanup_state)) {
|
|
347
|
+
errors.push({
|
|
348
|
+
code: 'invalid_cleanup_state',
|
|
349
|
+
path: `worktrees[${index}].cleanup_state`,
|
|
350
|
+
message: `Unsupported cleanup_state: ${entry.cleanup_state}`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
path: workspacePathLabel(workspaceRoot, annotationPath),
|
|
357
|
+
exists: true,
|
|
358
|
+
valid: errors.length === 0,
|
|
359
|
+
errors,
|
|
360
|
+
worktrees,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function reconcileAnnotations(worktrees, annotations) {
|
|
365
|
+
const warnings = [];
|
|
366
|
+
const byPath = new Map(worktrees.map((entry) => [normalizeSlashes(resolve(entry.path)), entry]));
|
|
367
|
+
const attached = new Map();
|
|
368
|
+
|
|
369
|
+
for (const annotation of annotations.worktrees) {
|
|
370
|
+
const worktree = annotation.normalized_path ? byPath.get(annotation.normalized_path) : null;
|
|
371
|
+
if (!worktree) {
|
|
372
|
+
warnings.push({
|
|
373
|
+
code: 'stale_annotation_missing_worktree',
|
|
374
|
+
severity: 'warn',
|
|
375
|
+
message: `Annotation ${annotation.id} points at a missing or unlisted worktree.`,
|
|
376
|
+
annotation_id: annotation.id,
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
attached.set(worktree.path, annotation);
|
|
381
|
+
if (annotation.branch && worktree.branch && annotation.branch !== worktree.branch) {
|
|
382
|
+
warnings.push({
|
|
383
|
+
code: 'stale_annotation_branch_mismatch',
|
|
384
|
+
severity: 'warn',
|
|
385
|
+
message: `Annotation ${annotation.id} branch ${annotation.branch} does not match live branch ${worktree.branch}.`,
|
|
386
|
+
annotation_id: annotation.id,
|
|
387
|
+
worktree_id: worktree.id,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (annotation.last_known_head && worktree.head && annotation.last_known_head !== worktree.head) {
|
|
391
|
+
warnings.push({
|
|
392
|
+
code: 'stale_annotation_head_mismatch',
|
|
393
|
+
severity: 'warn',
|
|
394
|
+
message: `Annotation ${annotation.id} last_known_head does not match live HEAD.`,
|
|
395
|
+
annotation_id: annotation.id,
|
|
396
|
+
worktree_id: worktree.id,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
worktrees: worktrees.map((worktree) => ({
|
|
403
|
+
...worktree,
|
|
404
|
+
annotation: attached.get(worktree.path) || null,
|
|
405
|
+
})),
|
|
406
|
+
warnings,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function classifyWorkflowState(planningDir) {
|
|
411
|
+
const lifecycle = evaluateLifecycleState({ planningDir });
|
|
412
|
+
const checkpointPath = join(planningDir, '.continue-here.md');
|
|
413
|
+
const drift = existsSync(planningDir) ? checkDrift(planningDir) : { drifted: false, details: [] };
|
|
414
|
+
const milestoneVersion = lifecycle.currentMilestone?.version || null;
|
|
415
|
+
const milestoneTitle = lifecycle.currentMilestone?.title || null;
|
|
416
|
+
const milestone = milestoneVersion || milestoneTitle
|
|
417
|
+
? { version: milestoneVersion, title: milestoneTitle }
|
|
418
|
+
: null;
|
|
419
|
+
return {
|
|
420
|
+
current_milestone: milestone,
|
|
421
|
+
current_phase: lifecycle.currentPhase?.number || null,
|
|
422
|
+
next_phase: lifecycle.nextPhase?.number || null,
|
|
423
|
+
non_phase_state: lifecycle.nonPhaseState || null,
|
|
424
|
+
checkpoint: {
|
|
425
|
+
exists: existsSync(checkpointPath),
|
|
426
|
+
path: '.planning/.continue-here.md',
|
|
427
|
+
},
|
|
428
|
+
planning_drift: {
|
|
429
|
+
drifted: drift.drifted,
|
|
430
|
+
details: drift.details || [],
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildRisks({ canonical, worktrees, annotations, runtimeDirs, workflowState, gitErrors }) {
|
|
436
|
+
const risks = [];
|
|
437
|
+
for (const error of gitErrors) {
|
|
438
|
+
risks.push({ code: error.code, severity: 'warn', message: error.message });
|
|
439
|
+
}
|
|
440
|
+
if (!canonical.git_valid) {
|
|
441
|
+
risks.push({ code: 'canonical_git_invalid', severity: 'warn', message: `Canonical worktree git status failed: ${canonical.status_error || 'unknown error'}` });
|
|
442
|
+
}
|
|
443
|
+
if (canonical.dirty.counts.tracked > 0 || canonical.dirty.counts.untracked > 0) {
|
|
444
|
+
risks.push({
|
|
445
|
+
code: 'canonical_dirty',
|
|
446
|
+
severity: 'warn',
|
|
447
|
+
message: `Canonical worktree has tracked/untracked changes (${canonical.dirty.counts.tracked} tracked, ${canonical.dirty.counts.untracked} untracked).`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (canonical.dirty.counts.ignored > 0) {
|
|
451
|
+
risks.push({
|
|
452
|
+
code: 'ignored_local_surfaces_present',
|
|
453
|
+
severity: 'info',
|
|
454
|
+
message: `Canonical worktree has ${canonical.dirty.counts.ignored} ignored local surface(s); do not describe the workspace as simply clean.`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
for (const worktree of worktrees.filter((entry) => entry.path !== canonical.path)) {
|
|
458
|
+
if (!worktree.git_valid) {
|
|
459
|
+
risks.push({ code: 'worktree_git_invalid', severity: 'warn', worktree_id: worktree.id, message: `Worktree ${worktree.id} could not be inspected by git.` });
|
|
460
|
+
}
|
|
461
|
+
if (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0) {
|
|
462
|
+
risks.push({
|
|
463
|
+
code: 'sibling_worktree_dirty',
|
|
464
|
+
severity: 'warn',
|
|
465
|
+
worktree_id: worktree.id,
|
|
466
|
+
message: `Sibling worktree ${worktree.id} has tracked/untracked changes.`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (!worktree.annotation && (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0 || worktree.detached)) {
|
|
470
|
+
risks.push({
|
|
471
|
+
code: 'unannotated_candidate_worktree',
|
|
472
|
+
severity: 'info',
|
|
473
|
+
worktree_id: worktree.id,
|
|
474
|
+
message: `Worktree ${worktree.id} has candidate-work signals but no local control-map annotation.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const warning of annotations.warnings || []) risks.push(warning);
|
|
479
|
+
for (const error of annotations.errors || []) {
|
|
480
|
+
risks.push({ code: error.code, severity: 'warn', message: error.message, path: error.path });
|
|
481
|
+
}
|
|
482
|
+
for (const runtimeDir of runtimeDirs.filter((entry) => !entry.appears_git_worktree)) {
|
|
483
|
+
risks.push({
|
|
484
|
+
code: 'orphan_runtime_dir',
|
|
485
|
+
severity: 'info',
|
|
486
|
+
message: `${runtimeDir.runtime} local directory is not a git worktree: ${runtimeDir.path_relative}`,
|
|
487
|
+
path: runtimeDir.path_relative,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (workflowState.planning_drift.drifted) {
|
|
491
|
+
risks.push({
|
|
492
|
+
code: 'planning_state_drift',
|
|
493
|
+
severity: 'warn',
|
|
494
|
+
message: `Planning state drifted since the last fingerprint: ${workflowState.planning_drift.details.join('; ')}`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return risks;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function buildInterventions(risks) {
|
|
501
|
+
const codes = new Set(risks.map((risk) => risk.code));
|
|
502
|
+
const interventions = [];
|
|
503
|
+
if (codes.has('canonical_git_invalid')) interventions.push('Fix git/safe.directory access before mutating this checkout.');
|
|
504
|
+
if (codes.has('canonical_dirty')) interventions.push('Checkpoint or classify canonical dirty work before planning, cleanup, merge, or broad execution.');
|
|
505
|
+
if (codes.has('sibling_worktree_dirty') || codes.has('unannotated_candidate_worktree')) interventions.push('Review sibling worktree ownership and write set before starting overlapping implementation.');
|
|
506
|
+
if (codes.has('stale_annotation_missing_worktree') || codes.has('stale_annotation_branch_mismatch') || codes.has('stale_annotation_head_mismatch')) interventions.push('Refresh or remove stale local control-map annotations after reviewing repo truth.');
|
|
507
|
+
if (codes.has('planning_state_drift')) interventions.push('Review planning drift and rebaseline with session-fingerprint only after confirming the changes are intentional.');
|
|
508
|
+
if (interventions.length === 0) interventions.push('No control-map intervention required before read-only status work.');
|
|
509
|
+
return interventions;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function buildControlMap({
|
|
513
|
+
workspaceRoot,
|
|
514
|
+
planningDir,
|
|
515
|
+
annotationPath = null,
|
|
516
|
+
includeIgnoredPaths = false,
|
|
517
|
+
now = new Date(),
|
|
518
|
+
} = {}) {
|
|
519
|
+
const root = resolve(workspaceRoot || process.cwd());
|
|
520
|
+
const planning = planningDir || join(root, '.planning');
|
|
521
|
+
const discovered = discoverGitWorktrees(root, { includeIgnoredPaths, includeIgnoredCount: includeIgnoredPaths });
|
|
522
|
+
const annotations = loadAnnotations(root, planning, annotationPath);
|
|
523
|
+
const reconciled = reconcileAnnotations(discovered.worktrees, annotations);
|
|
524
|
+
const canonical = reconciled.worktrees.find((entry) => resolve(entry.path) === root)
|
|
525
|
+
|| reconciled.worktrees[0]
|
|
526
|
+
|| readGitWorktree(root, root, { includeIgnoredCount: true });
|
|
527
|
+
const workflowState = classifyWorkflowState(planning);
|
|
528
|
+
const runtimeDirs = discoverRuntimeDirs(root);
|
|
529
|
+
const annotationReport = {
|
|
530
|
+
path: annotations.path,
|
|
531
|
+
exists: annotations.exists,
|
|
532
|
+
valid: annotations.valid,
|
|
533
|
+
errors: annotations.errors,
|
|
534
|
+
warnings: reconciled.warnings,
|
|
535
|
+
};
|
|
536
|
+
const risks = buildRisks({
|
|
537
|
+
canonical,
|
|
538
|
+
worktrees: reconciled.worktrees,
|
|
539
|
+
annotations: annotationReport,
|
|
540
|
+
runtimeDirs,
|
|
541
|
+
workflowState,
|
|
542
|
+
gitErrors: discovered.errors,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
schema_version: 1,
|
|
547
|
+
generated_at: now.toISOString(),
|
|
548
|
+
operation: 'control-map',
|
|
549
|
+
repo_root_id: canonical.git_top_level || normalizeSlashes(root),
|
|
550
|
+
workspace_root: normalizeSlashes(root),
|
|
551
|
+
default_annotations_path: DEFAULT_ANNOTATIONS_RELATIVE_PATH,
|
|
552
|
+
authority: AUTHORITY_ORDER,
|
|
553
|
+
canonical_worktree: canonical,
|
|
554
|
+
worktrees: reconciled.worktrees,
|
|
555
|
+
workflow_state: workflowState,
|
|
556
|
+
annotations: annotationReport,
|
|
557
|
+
runtime_dirs: runtimeDirs,
|
|
558
|
+
risks,
|
|
559
|
+
interventions: buildInterventions(risks),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function printHuman(map) {
|
|
564
|
+
const milestone = map.workflow_state.current_milestone
|
|
565
|
+
? [map.workflow_state.current_milestone.version, map.workflow_state.current_milestone.title].filter(Boolean).join(' ')
|
|
566
|
+
: 'none';
|
|
567
|
+
const ignoredLabel = map.canonical_worktree.dirty.ignored_count_included
|
|
568
|
+
? String(map.canonical_worktree.dirty.counts.ignored)
|
|
569
|
+
: 'not scanned';
|
|
570
|
+
console.log('gsdd control-map - computed workspace control map\n');
|
|
571
|
+
console.log(`Workspace: ${map.workspace_root}`);
|
|
572
|
+
console.log(`Authority: ${map.authority.join(' > ')}`);
|
|
573
|
+
console.log(`Canonical: ${map.canonical_worktree.branch || 'unknown'} @ ${map.canonical_worktree.head || 'unknown'}`);
|
|
574
|
+
console.log(`Workflow: milestone=${milestone}, phase=${map.workflow_state.current_phase || 'none'}, next=${map.workflow_state.next_phase || 'none'}`);
|
|
575
|
+
console.log(`Checkpoint: ${map.workflow_state.checkpoint.path} (${map.workflow_state.checkpoint.exists ? 'present' : 'missing'})`);
|
|
576
|
+
console.log(`Dirty buckets: ${map.canonical_worktree.dirty.counts.tracked} tracked, ${map.canonical_worktree.dirty.counts.untracked} untracked, ${ignoredLabel} ignored`);
|
|
577
|
+
console.log(`Worktrees: ${map.worktrees.length}`);
|
|
578
|
+
for (const worktree of map.worktrees) {
|
|
579
|
+
const marker = worktree.path === map.canonical_worktree.path ? '*' : '-';
|
|
580
|
+
const annotation = worktree.annotation ? ` owner=${worktree.annotation.runtime_owner || 'unspecified'} cleanup=${worktree.annotation.cleanup_state}` : '';
|
|
581
|
+
const ignoredCount = worktree.dirty.ignored_count_included ? String(worktree.dirty.counts.ignored) : 'not-scanned';
|
|
582
|
+
console.log(` ${marker} ${worktree.id} branch=${worktree.branch || 'unknown'} dirty=${worktree.dirty.counts.tracked}/${worktree.dirty.counts.untracked}/${ignoredCount}${annotation}`);
|
|
583
|
+
}
|
|
584
|
+
if (map.risks.length > 0) {
|
|
585
|
+
console.log('\nRisks:');
|
|
586
|
+
for (const risk of map.risks) console.log(` - [${risk.severity || 'info'}] ${risk.code}: ${risk.message}`);
|
|
587
|
+
}
|
|
588
|
+
console.log('\nInterventions:');
|
|
589
|
+
for (const intervention of map.interventions) console.log(` - ${intervention}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function cmdControlMap(...args) {
|
|
593
|
+
const jsonMode = args.includes('--json');
|
|
594
|
+
const contextArgs = args.filter((arg) => arg !== '--json');
|
|
595
|
+
const context = resolveWorkspaceContext(contextArgs);
|
|
596
|
+
if (context.invalid) {
|
|
597
|
+
console.error(context.error);
|
|
598
|
+
process.exitCode = 1;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const annotations = parseFlagValue(context.args, '--annotations');
|
|
603
|
+
if (annotations.invalid) {
|
|
604
|
+
console.error('Usage: gsdd control-map [--json] [--with-ignored] [--annotations <path>]');
|
|
605
|
+
process.exitCode = 1;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const filteredArgs = context.args.filter((arg, index) => {
|
|
609
|
+
if (arg === '--annotations') return false;
|
|
610
|
+
if (index > 0 && context.args[index - 1] === '--annotations') return false;
|
|
611
|
+
if (arg === '--with-ignored') return false;
|
|
612
|
+
return true;
|
|
613
|
+
});
|
|
614
|
+
if (filteredArgs.length > 0) {
|
|
615
|
+
console.error('Usage: gsdd control-map [--json] [--with-ignored] [--annotations <path>]');
|
|
616
|
+
process.exitCode = 1;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const includeIgnoredPaths = context.args.includes('--with-ignored');
|
|
621
|
+
const map = buildControlMap({
|
|
622
|
+
workspaceRoot: context.workspaceRoot,
|
|
623
|
+
planningDir: context.planningDir,
|
|
624
|
+
annotationPath: annotations.value,
|
|
625
|
+
includeIgnoredPaths,
|
|
626
|
+
});
|
|
627
|
+
if (jsonMode) output(map);
|
|
628
|
+
else printHuman(map);
|
|
629
|
+
}
|
package/bin/lib/health.mjs
CHANGED
|
@@ -147,13 +147,13 @@ export function createCmdHealth(ctx) {
|
|
|
147
147
|
const parsed = readUiProofBundleFile(bundlePath);
|
|
148
148
|
const validation = parsed.errors.length > 0
|
|
149
149
|
? { valid: false, errors: parsed.errors }
|
|
150
|
-
: validateUiProofBundle(parsed.bundle);
|
|
150
|
+
: validateUiProofBundle(parsed.bundle, { requireLocalArtifactExists: true, workspaceRoot: cwd });
|
|
151
151
|
if (!validation.valid) {
|
|
152
152
|
errors.push({
|
|
153
153
|
id: 'E10',
|
|
154
154
|
severity: 'ERROR',
|
|
155
155
|
message: `${relativePath} has invalid UI proof metadata (${validation.errors.map((entry) => entry.code).join(', ')})`,
|
|
156
|
-
fix: 'Run `gsdd ui-proof validate <path>` and add required privacy metadata, claim limits, fixed evidence kinds, observation artifact references, and safe-to-publish handling.',
|
|
156
|
+
fix: 'Run `gsdd ui-proof validate <path>` and add required privacy metadata, claim limits, fixed evidence kinds, concise tool provenance, failure classification when failed or partial, observation artifact references, existing local artifact paths, and safe-to-publish handling.',
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
159
|
}
|