gsdd-cli 0.22.0 → 0.24.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 CHANGED
@@ -69,7 +69,7 @@ Invariant suites (I-series), guard suites (G-series), and scenario suites (S-ser
69
69
 
70
70
  ### Smaller surface, stricter closure
71
71
 
72
- 14 workflows. 10 roles. One CLI. Lifecycle progression goes through deterministic preflight gates — not conversational inference. Plans are checked by a separate agent in a separate context before execution begins. Closure requires evidence, not just file existence.
72
+ 4 main workflows, 14 public workflow surfaces, 10 roles, one CLI. The daily spine is `new-project -> plan -> execute -> verify`; milestone, quick, pause/resume, progress, audit, and mapping surfaces support that spine. Lifecycle progression goes through deterministic preflight gates — not conversational inference. Plans are checked by a separate agent in a separate context before execution begins. Closure requires evidence, not just file existence.
73
73
 
74
74
  <details>
75
75
  <summary>How it works</summary>
@@ -346,6 +346,7 @@ Workflows are agent skills or commands, not plain shell utilities. How you invok
346
346
  | `npx -y gsdd-cli init [--tools <platform>]` | Set up `.planning/`, generate skills/adapters |
347
347
  | `npx -y gsdd-cli update [--tools <platform>] [--templates]` | Regenerate skills/adapters and refresh the repo-local helper runtime; `--templates` refreshes `.planning/templates/` and role contracts |
348
348
  | `npx -y gsdd-cli health [--json]` | Check workspace integrity and generated-surface freshness (healthy/degraded/broken) |
349
+ | `npx -y gsdd-cli control-map [--json] [--with-ignored]` | Report computed repo/worktree/planning state, dirty buckets, optional ignored-path scan, local annotations, and safe next interventions |
349
350
  | `npx -y gsdd-cli ui-proof validate <path> [--claim <public\|publication\|tracked\|delivery\|release>]` | Validate UI proof bundle metadata without requiring browser tooling; use `--claim` only when validating that stronger proof use |
350
351
  | `npx -y gsdd-cli file-op <copy\|delete\|regex-sub>` | Run deterministic workspace-confined file copy, delete, and regex substitution |
351
352
  | `npx -y gsdd-cli find-phase [N]` | Show phase info as JSON (for agent consumption) |
@@ -415,6 +416,7 @@ Model IDs pass through a two-layer injection guard: a regex whitelist (`/^[a-zA-
415
416
  | `.planning/research/` | Research outputs |
416
417
  | `.planning/codebase/` | Codebase maps (4 files) |
417
418
  | `.planning/quick/` | Quick task tracking |
419
+ | `.planning/.local/` | Local-only operational annotations such as control-map intent; never product truth |
418
420
  | `.planning/.continue-here.md` | Session checkpoint (created by pause, consumed by resume) |
419
421
 
420
422
  ### Advisory Git Protocol
package/bin/gsdd.mjs CHANGED
@@ -20,6 +20,7 @@ import { createCmdHealth } from './lib/health.mjs';
20
20
  import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
21
21
  import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
22
22
  import { cmdUiProof } from './lib/ui-proof.mjs';
23
+ import { cmdControlMap } from './lib/control-map.mjs';
23
24
  import { resolveWorkspaceContext } from './lib/workspace-root.mjs';
24
25
 
25
26
  const __filename = fileURLToPath(import.meta.url);
@@ -86,7 +87,6 @@ function createCliContext(cwd = process.cwd()) {
86
87
  }
87
88
 
88
89
  const INIT_CONTEXT = createCliContext(process.cwd());
89
-
90
90
  const cmdInit = createCmdInit(INIT_CONTEXT);
91
91
  const cmdHealth = createCmdHealth(INIT_CONTEXT);
92
92
 
@@ -109,6 +109,7 @@ const COMMANDS = {
109
109
  'lifecycle-preflight': cmdLifecyclePreflight,
110
110
  'session-fingerprint': cmdSessionFingerprint,
111
111
  'ui-proof': cmdUiProof,
112
+ 'control-map': cmdControlMap,
112
113
  'find-phase': cmdFindPhase,
113
114
  'phase-status': cmdPhaseStatus,
114
115
  verify: cmdVerify,
@@ -135,4 +136,4 @@ async function runCli(cliCommand = command, ...cliArgs) {
135
136
  }
136
137
 
137
138
  if (IS_MAIN) await runCli();
138
- export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
139
+ export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdControlMap, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
@@ -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
+ }