gsdd-cli 0.22.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 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
+ }
@@ -100,6 +100,7 @@ export function createCmdInit(ctx) {
100
100
  promptApi,
101
101
  preselectedConfig: interactiveSession.config,
102
102
  });
103
+ ensureGitignoreEntry(ctx.cwd, '.planning/.local/', ' - ensured .planning/.local/ is gitignored');
103
104
 
104
105
  if (briefSource) {
105
106
  cpSync(briefSource, join(ctx.planningDir, 'PROJECT_BRIEF.md'));
@@ -273,7 +274,7 @@ async function ensureConfig({ cwd, planningDir, isAuto, promptApi, preselectedCo
273
274
  if (preselectedConfig) {
274
275
  writeFileSync(configFile, JSON.stringify(preselectedConfig, null, 2));
275
276
  console.log(' - saved .planning/config.json (guided wizard)\n');
276
- if (!preselectedConfig.commitDocs) ensureGitignoreEntry(cwd);
277
+ if (!preselectedConfig.commitDocs) ensureGitignoreEntry(cwd, '.planning/', ' - ensured .planning/ is gitignored');
277
278
  return;
278
279
  }
279
280
 
@@ -281,7 +282,7 @@ async function ensureConfig({ cwd, planningDir, isAuto, promptApi, preselectedCo
281
282
  const config = buildDefaultConfig({ autoAdvance: true });
282
283
  writeFileSync(configFile, JSON.stringify(config, null, 2));
283
284
  console.log(' - wrote .planning/config.json (auto defaults)\n');
284
- if (!config.commitDocs) ensureGitignoreEntry(cwd);
285
+ if (!config.commitDocs) ensureGitignoreEntry(cwd, '.planning/', ' - ensured .planning/ is gitignored');
285
286
  return;
286
287
  }
287
288
 
@@ -289,7 +290,7 @@ async function ensureConfig({ cwd, planningDir, isAuto, promptApi, preselectedCo
289
290
  const config = buildDefaultConfig({ autoAdvance: false });
290
291
  writeFileSync(configFile, JSON.stringify(config, null, 2));
291
292
  console.log(' - wrote .planning/config.json (non-interactive defaults)\n');
292
- if (!config.commitDocs) ensureGitignoreEntry(cwd);
293
+ if (!config.commitDocs) ensureGitignoreEntry(cwd, '.planning/', ' - ensured .planning/ is gitignored');
293
294
  return;
294
295
  }
295
296
 
@@ -303,18 +304,17 @@ async function ensureConfig({ cwd, planningDir, isAuto, promptApi, preselectedCo
303
304
 
304
305
  writeFileSync(configFile, JSON.stringify(selected, null, 2));
305
306
  console.log(' - saved .planning/config.json (guided wizard)\n');
306
- if (!selected.commitDocs) ensureGitignoreEntry(cwd);
307
+ if (!selected.commitDocs) ensureGitignoreEntry(cwd, '.planning/', ' - ensured .planning/ is gitignored');
307
308
  }
308
309
 
309
- function ensureGitignoreEntry(cwd) {
310
+ function ensureGitignoreEntry(cwd, entry, message) {
310
311
  const gitignorePath = join(cwd, '.gitignore');
311
- const entry = '.planning/';
312
312
  const hasGitignore = existsSync(gitignorePath);
313
313
  const current = hasGitignore ? readFileSync(gitignorePath, 'utf-8') : '';
314
314
  if (!current.split(/\r?\n/).includes(entry)) {
315
315
  const next = current.trimEnd() ? `${current.trimEnd()}\n${entry}\n` : `${entry}\n`;
316
316
  writeFileSync(gitignorePath, next);
317
- console.log(' - ensured .planning/ is gitignored');
317
+ console.log(message);
318
318
  }
319
319
  }
320
320
 
@@ -192,6 +192,8 @@ Commands:
192
192
  Validate UI proof metadata; use --claim for stronger proof uses
193
193
  ui-proof compare <planned-slots-json> [observed-bundle-json ...]
194
194
  Compare planned UI proof slots against observed bundles
195
+ control-map [--json] [--with-ignored] [--annotations <path>]
196
+ Report computed repo/worktree/planning state and local annotations
195
197
  help Show this summary
196
198
 
197
199
  Platforms (for --tools):
@@ -263,6 +265,7 @@ Advanced/internal helpers (kept available, but not the primary first-run user st
263
265
  session-fingerprint Rebaseline the local planning-state fingerprint after review
264
266
  phase-status Update ROADMAP.md phase status through the local helper surface
265
267
  ui-proof Validate UI proof metadata and compare planned slots to observed bundles
268
+ control-map Report computed repo/worktree/planning state and local annotations
266
269
  file-op Deterministic workspace-confined file copy/delete/text mutation
267
270
  `;
268
271
  }
@@ -7,6 +7,7 @@ const __dirname = dirname(__filename);
7
7
  const DISTILLED_DIR = join(__dirname, '..', '..', 'distilled');
8
8
  const HELPER_LIB_FILES = Object.freeze([
9
9
  'cli-utils.mjs',
10
+ 'control-map.mjs',
10
11
  'evidence-contract.mjs',
11
12
  'file-ops.mjs',
12
13
  'lifecycle-preflight.mjs',
@@ -49,6 +50,7 @@ import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
49
50
  import { cmdPhaseStatus } from './lib/phase.mjs';
50
51
  import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
51
52
  import { cmdUiProof } from './lib/ui-proof.mjs';
53
+ import { cmdControlMap } from './lib/control-map.mjs';
52
54
  import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs';
53
55
 
54
56
  const COMMANDS = {
@@ -57,6 +59,7 @@ const COMMANDS = {
57
59
  'phase-status': cmdPhaseStatus,
58
60
  'session-fingerprint': cmdSessionFingerprint,
59
61
  'ui-proof': cmdUiProof,
62
+ 'control-map': cmdControlMap,
60
63
  };
61
64
 
62
65
  function printHelp() {
@@ -78,6 +81,8 @@ function printHelp() {
78
81
  ' Validate UI proof metadata; use --claim for stronger proof uses',
79
82
  ' ui-proof compare <planned-slots-json> [observed-bundle-json ...]',
80
83
  ' Compare planned UI proof slots against observed bundles',
84
+ ' control-map [--json] [--with-ignored] [--annotations <path>]',
85
+ ' Report computed repo/worktree/planning state and local annotations',
81
86
  '',
82
87
  'Advanced option:',
83
88
  ' --workspace-root <path> Override workspace root discovery before or after the subcommand',
@@ -73,6 +73,7 @@
73
73
  60. [Release Closeout Contract](#d60---release-closeout-contract)
74
74
  61. [Deliberate Subagent Contract](#d61---deliberate-subagent-contract)
75
75
  62. [Repo-Native UI Proof Contract](#d62---repo-native-ui-proof-contract)
76
+ 63. [Computed-First Control Map](#d63---computed-first-control-map)
76
77
 
77
78
  ---
78
79
 
@@ -2872,6 +2873,44 @@ Posture compatibility is part of that closeout contract: `repo_closeout` and `ru
2872
2873
  - Generated runtime surfaces and local templates must stay freshness-checkable through `gsdd update --templates` and health diagnostics.
2873
2874
  - Future provider/tooling work must not make `agent-browser` a required validator field without a separate product decision; the current contract makes it the default workflow path, not a schema lock.
2874
2875
 
2876
+ ## D63 - Computed-First Control Map
2877
+
2878
+ **Decision (2026-05-08):** Long-running multi-agent and multi-worktree control uses a computed-first `gsdd control-map` helper rather than a new lifecycle workflow or a vendor session parser. The helper computes repo/worktree/planning truth live and overlays optional local annotations only for intent that git cannot know.
2879
+
2880
+ **Context:**
2881
+ - Gap I52 showed that ordinary `git status` can be clean while sibling worktrees, detached runtime worktrees, ignored/generated surfaces, snapshots, dirty local WIP, and cleanup obligations remain unexplained.
2882
+ - Gap I54 showed that repeated subagent swarms can become a substitute for shared state when there is no one-screen control map for active branches, ownership, proof state, and cleanup debt.
2883
+ - Current runtime research shows the only portable cross-vendor coordination layer is repo artifacts plus generated workflow entrypoints. Claude, OpenCode, Codex, Cursor, Copilot, and Gemini do not expose one uniform authoritative session/worktree store.
2884
+ - Current harness guidance favors structured handoff artifacts, worktree isolation, evaluator loops, approval gates around side effects, and browser/runtime evidence. Those ideas fit Workspine only if repo truth remains primary and vendor adapters stay thin.
2885
+
2886
+ **Decision:**
2887
+ - Add `gsdd control-map [--json] [--with-ignored] [--annotations <path>]` to the main CLI and generated `.planning/bin/gsdd.mjs` helper runtime.
2888
+ - Compute authority from live git/worktree state first: canonical checkout, branch, HEAD, upstream divergence when comparable, tracked/untracked dirty buckets, optional ignored-path scans through `--with-ignored`, sibling git worktrees, detached/bare state, invalid git access, planning drift, checkpoint existence, lifecycle state, and repo-local runtime worktree directories.
2889
+ - Read optional annotations from `.planning/.local/control-map.annotations.json`. Annotations may record `runtime_owner`, intended scope, write set, cleanup state, proof state, next step, branch, and last known head.
2890
+ - Treat annotations as stale-checkable intent only. They never outrank repo truth, planning artifacts, or checkpoint reconciliation.
2891
+ - Keep transcript/session stores out of the helper. Vendor session evidence may support postmortems, but it is not live product truth.
2892
+ - Wire the control map into portable workflow behavior by having `progress`, `resume`, `pause`, `quick`, `plan`, and `execute` consult it when available. This is guidance plus deterministic helper output, not a new workflow lane.
2893
+
2894
+ **Leverage:**
2895
+ - Lost: a pure zero-file model cannot preserve non-computable intent such as owner/runtime, intended scope, and cleanup obligation.
2896
+ - Kept: Workspine remains a lightweight repo-native spine; no new lifecycle workflow, no dashboard/control plane, no vendor session authority, and no change to the five evidence kinds.
2897
+ - Gained: agents can explain "clean" precisely across tracked, untracked, sibling, detached, stale, and annotated state by default, and across ignored/generated local surfaces when the caller requests the explicit `--with-ignored` scan before planning, execution, resume, cleanup, or milestone continuation.
2898
+
2899
+ **Evidence:**
2900
+ - `bin/lib/control-map.mjs`, `bin/gsdd.mjs`, `bin/lib/rendering.mjs`
2901
+ - `distilled/workflows/progress.md`, `resume.md`, `pause.md`, `quick.md`, `plan.md`, `execute.md`
2902
+ - `tests/gsdd.control-map.test.cjs`
2903
+ - `.internal-research/gaps.md` Gap I52 and Gap I54
2904
+ - `.internal-research/lessons-learned.md` entries on multi-worktree registry, clean-vs-editor-visible noise, checkpoint/worktree truth split, and subagent stop conditions
2905
+ - GSD comparison: upstream GSD preserves lifecycle rigor but does not define a vendor-agnostic computed worktree/control-map helper.
2906
+ - OpenSpec comparison: OpenSpec optimizes change-level speed and archive flow, but does not own long-running multi-worktree local-state reconciliation as a portable harness surface.
2907
+ - Harness sources: `https://www.anthropic.com/engineering/harness-design-long-running-apps`, `https://code.claude.com/docs/en/worktrees`, `https://developers.openai.com/codex/cloud`, `https://developers.openai.com/api/docs/guides/agents/orchestration`, `https://developers.openai.com/api/docs/guides/agents/guardrails-approvals`, `https://developers.openai.com/api/docs/guides/agent-evals`, `https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent`, `https://agent-browser.dev/sessions`, and `https://developer.chrome.com/docs/devtools/agents`.
2908
+
2909
+ **Consequences:**
2910
+ - Future cleanup, resume, and parallel-worktree work should start from `gsdd control-map --json` rather than repeated ad hoc repo audits; use `--with-ignored` before making a clean-workspace claim that includes ignored or generated surfaces.
2911
+ - A future mutation command may update annotations, but the current helper intentionally stays computed/read-first and safe to call from status surfaces.
2912
+ - Future health or preflight hardening can consume the same helper output for stricter blocking, but must avoid turning local annotations into product truth.
2913
+
2875
2914
  ---
2876
2915
 
2877
2916
  ## Maintenance
@@ -505,6 +505,16 @@
505
505
  - Long-term pitfalls carried forward: do not accept screenshot-free "looks good" claims, weak planned slots, unverified artifact paths, stale interactive refs after page mutation, partial/failed proof without failure classification, raw artifact publication without privacy metadata, browser contention in parallel checks, or semantic/selector-poor UI that forces fragile coordinate inspection.
506
506
  - Supporting spec/runtime docs: https://openspec.dev/, https://www.lean-spec.dev/docs/guide/first-principles, https://help.openai.com/en/articles/11369540-codex-in-chatgpt, https://docs.claude.com/en/docs/agents-and-tools/agent-skills, https://docs.github.com/en/copilot/concepts/prompting/response-customization
507
507
 
508
+ ## D63 — Computed-First Control Map
509
+ - `bin/lib/control-map.mjs`, `bin/gsdd.mjs`, `bin/lib/rendering.mjs`
510
+ - `distilled/workflows/progress.md`, `resume.md`, `pause.md`, `quick.md`, `plan.md`, `execute.md`
511
+ - `tests/gsdd.control-map.test.cjs`
512
+ - `.internal-research/gaps.md` Gap I52 and Gap I54
513
+ - `.internal-research/lessons-learned.md` multi-worktree registry, clean-vs-editor-visible noise, checkpoint/worktree truth split, and subagent stop-condition lessons
514
+ - GSD comparison: upstream GSD lifecycle rigor does not include a vendor-agnostic computed worktree/control-map helper
515
+ - OpenSpec comparison: OpenSpec change archive flow does not own long-running multi-worktree local-state reconciliation as a portable harness surface
516
+ - Harness sources: https://www.anthropic.com/engineering/harness-design-long-running-apps, https://code.claude.com/docs/en/worktrees, https://developers.openai.com/codex/cloud, https://developers.openai.com/api/docs/guides/agents/orchestration, https://developers.openai.com/api/docs/guides/agents/guardrails-approvals, https://developers.openai.com/api/docs/guides/agent-evals, https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent, https://agent-browser.dev/sessions, https://developer.chrome.com/docs/devtools/agents
517
+
508
518
  ---
509
519
 
510
520
  ## Maintenance
@@ -71,6 +71,14 @@ npx -y gsdd-cli init -> bootstrap (create .planning/, copy templates, gene
71
71
  /gsdd-progress -> show status, route to next action
72
72
  ```
73
73
 
74
+ The main operator spine is four workflow moves after bootstrap: `new-project -> plan -> execute -> verify`. The other public workflow surfaces are support lanes for milestone closeout, quick work, progress, pause/resume, and brownfield orientation.
75
+
76
+ Helper command for long-running sessions:
77
+
78
+ ```
79
+ npx -y gsdd-cli control-map [--json] [--with-ignored] -> computed repo/worktree/planning state plus local annotations
80
+ ```
81
+
74
82
  ## Brownfield Entry Contract
75
83
 
76
84
  Use the same three-way routing everywhere:
@@ -101,6 +109,7 @@ Use the same three-way routing everywhere:
101
109
  Architecture notes:
102
110
  - `bin/gsdd.mjs` remains the thin generator entrypoint, while vendor-specific rendering lives in adapter modules.
103
111
  - Codex CLI uses the always-generated `.agents/skills/gsdd-*` surface as its entry path, relies on `.planning/bin/gsdd.mjs` for deterministic helper calls, and can add a native `.codex/agents/gsdd-plan-checker.toml` checker agent.
112
+ - `control-map` is a helper command, not a lifecycle workflow: it computes repo/worktree/planning truth first and treats `.planning/.local/` annotations as local intent only.
104
113
  - Codex VS Code/app are separate surfaces from Codex CLI; do not claim the CLI proof for them unless they expose compatible skill discovery. Fallback is opening or pasting the generated `SKILL.md`.
105
114
  - `npx -y gsdd-cli health` now compares any installed generated runtime surfaces against current render output and routes repairs back through `npx -y gsdd-cli update`.
106
115
  - Portable lifecycle contracts now align to the roadmap template status grammar: `[ ]`, `[-]`, `[x]`.
@@ -1,8 +1,7 @@
1
1
  <role>
2
2
  You are the EXECUTOR. Your job is to implement the tasks from a phase plan with precision and discipline.
3
3
 
4
- You follow the plan. You verify before reporting completion. You document deviations.
5
- You DO NOT freelance. You DO NOT add features outside the plan.
4
+ You follow the plan, verify before reporting completion, document deviations, and DO NOT freelance or add features outside the plan.
6
5
  </role>
7
6
 
8
7
  <load_context>
@@ -36,7 +35,8 @@ Treat the preflight as an authorization seam over shared repo truth only:
36
35
  - it does not mutate `.planning/ROADMAP.md` by itself
37
36
  - owned writes remain execution artifacts, and ROADMAP mutation stays explicit in `<state_updates>` via `node .planning/bin/gsdd.mjs phase-status`
38
37
  </lifecycle_preflight>
39
-
38
+ <control_map_check>Before code mutation, run `node .planning/bin/gsdd.mjs control-map --json` when available. Confirm the intended execution surface, dirty buckets, sibling/detached worktrees, and overlapping write-set risk. If it reports stale annotations, dubious git access, dirty out-of-plan canonical files, or unannotated dirty sibling worktrees, stop or ask for explicit acknowledgement before broad writes. Local annotations are intent hints only; computed repo/worktree truth stays primary.
39
+ </control_map_check>
40
40
  <runtime_contract>
41
41
  Execution uses the same `Runtime` and `Assurance` types as planning and verification.
42
42
  Infer runtime from the launching surface when obvious: `.claude/` -> `claude-code`, `.codex/` or Codex portable skill -> `codex-cli`, `.opencode/` -> `opencode`, otherwise `other`.
@@ -39,6 +39,8 @@ Store the detected work type as `$WORK_TYPE` (one of: `phase`, `quick`, `generic
39
39
  <gather_state>
40
40
  Build a draft checkpoint from artifact truth before asking the user to restate work. The user should correct the draft, not rewrite obvious repo state from scratch.
41
41
 
42
+ When available, run `node .planning/bin/gsdd.mjs control-map --json` and use it as the draft's repo/worktree snapshot: canonical branch/HEAD, dirty tracked/untracked/ignored buckets, sibling/detached worktrees, stale annotations, planning drift, and recommended interventions. Include only a compact summary or pointer in `.planning/.continue-here.md`; the checkpoint records resumability context, not a replacement for future computed repo truth.
43
+
42
44
  Ask the user conversationally to fill in the gaps the artifacts cannot answer:
43
45
 
44
46
  1. **What was completed** this session
@@ -32,12 +32,12 @@ If the preflight result is `blocked`, STOP and report the blocker instead of inf
32
32
 
33
33
  <integration_surface_check>
34
34
  Before planning roadmap work, inspect the live integration surface separately from checkpoint or planning artifacts:
35
- - current branch name
36
- - divergence from `main` when available
37
- - staged, unstaged, and untracked local truth
38
- - whether the current branch appears stale/spent or mixed-scope
35
+ - Run `node .planning/bin/gsdd.mjs control-map --json` when available.
36
+ - Use its computed branch/HEAD, divergence, tracked/untracked/ignored buckets, sibling/detached worktrees, local annotations, and interventions.
37
+ - If the helper is unavailable, fall back to direct git/worktree inspection.
39
38
 
40
- If the planning truth says "next phase is X" but the git/worktree truth says the current branch is a stale or mixed execution surface, warn explicitly and treat the dirty branch as evidence only. Do not silently assume the checked-out branch is the right planning surface just because it exists.
39
+ If the planning truth says "next phase is X" but the git/worktree truth says the current branch is a stale/spent or mixed-scope execution surface, warn explicitly and treat the dirty branch as evidence only. Do not silently assume the checked-out branch is the right planning surface just because it exists.
40
+ Local annotations explain operator intent but do not outrank repo truth, planning artifacts, or checkpoint reconciliation.
41
41
  </integration_surface_check>
42
42
 
43
43
  <runtime_contract>
@@ -6,6 +6,10 @@ Core mindset: derive state from primary artifacts. ROADMAP.md checkboxes, phase
6
6
  Scope boundary: you are NOT resume.md. You do not wait for user input, clean up checkpoints, present interactive menus, or trigger any action. You report and suggest only.
7
7
  </role>
8
8
 
9
+ <control_map>
10
+ At the start of status reporting, run `node .planning/bin/gsdd.mjs control-map --json` when the local helper exists. Summarize its computed repo/worktree/planning state in the status block: canonical branch/HEAD, tracked/untracked dirty buckets, whether ignored paths were scanned, sibling or detached worktrees, stale local annotations, planning drift, and recommended interventions. Use `--with-ignored` before making a clean-workspace claim that includes ignored or generated surfaces. Treat the command output as read-only computed evidence. Local annotations under `.planning/.local/` explain intent but never outrank repo truth, planning artifacts, or checkpoint reconciliation.
11
+ </control_map>
12
+
9
13
  <prerequisites>
10
14
  `.planning/` must exist (from `npx -y gsdd-cli init`, or `gsdd init` when globally installed).
11
15
 
@@ -45,7 +45,7 @@ Store the response as `$DESCRIPTION`. If empty, re-prompt.
45
45
  6. If `.planning/codebase/` exists, read whichever of `.planning/codebase/ARCHITECTURE.md`, `.planning/codebase/STACK.md`, `.planning/codebase/CONVENTIONS.md`, and `.planning/codebase/CONCERNS.md` are present. Summarize key findings from available docs in <=500 words as `$CODEBASE_CONTEXT`, emphasizing: safest surfaces to touch, risky zones to avoid, must-know conventions/traps, and what must be re-verified after change. Note any missing docs in the summary.
46
46
  7. If `.planning/codebase/` does not exist, build a just-enough inline brownfield baseline instead of stopping. Read the repo root guidance that is cheap and stable (`README.md`, root manifest such as `package.json` / `pyproject.toml` / `Cargo.toml` when present, top-level app entrypoints, and any obviously relevant config or module files surfaced by `$DESCRIPTION`). Summarize the findings in <=500 words as `$CODEBASE_CONTEXT`, explicitly labeling it as a provisional baseline and calling out unknowns. Emphasize: likely implementation surface, likely dependency boundaries, conventions already visible, risky areas to avoid touching blindly, and what must be re-verified after the change. If the repo is still too unclear after this pass, keep that uncertainty explicit so Step 3.6 can recommend `/gsdd-map-codebase`.
47
47
  8. **Session-boundary fallback:** If `.planning/.continue-here.bak` exists, read its `<judgment>` section. Use `<active_constraints>` and `<anti_regression>` rules as task-scoping context (do not violate active constraints; do not regress on listed invariants). After reading, run `node .planning/bin/gsdd.mjs file-op delete .planning/.continue-here.bak --missing ok` (auto-clean).
48
- 9. Inspect the live branch/worktree surface separately from checkpoint or planning artifacts. If the current branch appears stale/spent, PR-less with overlapping write scope, or otherwise like the wrong execution surface, warn before proceeding. This is advisory for quick tasks unless the mismatch makes the task description materially misleading.
48
+ 9. Inspect the live branch/worktree surface separately from checkpoint or planning artifacts. Run `node .planning/bin/gsdd.mjs control-map --json` when available and use its computed repo/worktree/planning truth to identify stale/spent branches, dirty tracked/untracked/ignored buckets, sibling or detached worktrees, local annotations, and cleanup obligations. This is advisory for quick tasks unless the mismatch makes the task description materially misleading; local annotations are intent hints, not product truth.
49
49
 
50
50
  If `.planning/quick/` does not exist, create it along with an empty `LOG.md`:
51
51
 
@@ -35,6 +35,10 @@ Treat the preflight as an authorization seam over shared repo truth only:
35
35
 
36
36
  <process>
37
37
 
38
+ <control_map_reconciliation>
39
+ Before routing from a checkpoint, run `node .planning/bin/gsdd.mjs control-map --json` when available. Reconcile the checkpoint narrative against computed repo/worktree/planning truth: canonical branch/HEAD, tracked/untracked/ignored dirty buckets, sibling or detached worktrees, local annotations, active brownfield anchors, and planning drift. If the checkpoint understates dirty work, points at a stale branch/worktree, or conflicts with the active planning/brownfield surface, stop and present the mismatch before recommending execution. Local annotations are useful intent hints, not authority.
40
+ </control_map_reconciliation>
41
+
38
42
  <detect_state>
39
43
  Check for project artifacts in order:
40
44
 
@@ -185,6 +185,7 @@ The 7 check dimensions: requirement coverage, task completeness, dependency corr
185
185
  | `npx -y gsdd-cli init [--tools <platform>]` | Set up `.planning/`, generate skills/adapters |
186
186
  | `npx -y gsdd-cli update [--tools <platform>]` | Regenerate skills/adapters from latest sources |
187
187
  | `npx -y gsdd-cli update --templates` | Refresh role contracts and delegates (warns about user modifications) |
188
+ | `npx -y gsdd-cli control-map [--json] [--with-ignored]` | Show computed repo/worktree/planning state, dirty buckets, optional ignored-path scan, local annotations, and safe next interventions |
188
189
  | `npx -y gsdd-cli find-phase [N]` | Show phase info as JSON (for agent consumption) |
189
190
  | `npx -y gsdd-cli verify <N>` | Run artifact checks for phase N |
190
191
  | `npx -y gsdd-cli scaffold phase <N> [name]` | Create a new phase plan file |
@@ -447,6 +448,7 @@ Switch to budget profile: `npx -y gsdd-cli models profile budget` (or `gsdd mode
447
448
  gsdd.ps1 # PowerShell shim for the local helper surface
448
449
  lib/ # Copied helper-runtime support modules
449
450
  generation-manifest.json # SHA-256 hashes for template versioning
451
+ .local/ # Local-only operational annotations; not product truth
450
452
  .continue-here.md # Session checkpoint (created by pause, consumed by resume)
451
453
  research/ # Domain research outputs
452
454
  codebase/ # Codebase maps (4 files: STACK, ARCHITECTURE, CONVENTIONS, CONCERNS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsdd-cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Workspine — a repo-native delivery spine for long-horizon AI-assisted work, with directly validated support for Claude Code, Codex CLI, and OpenCode, published as gsdd-cli.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "npm run test:gsdd",
11
- "test:gsdd": "node tests/gsdd.init.test.cjs && node tests/gsdd.models.test.cjs && node tests/gsdd.consumer-ceremony.test.cjs && node tests/gsdd.manifest.test.cjs && node tests/gsdd.plan.adapters.test.cjs && node tests/gsdd.audit-milestone.test.cjs && node tests/gsdd.invariants.test.cjs && node tests/gsdd.guards.test.cjs && node tests/gsdd.health.test.cjs && node tests/gsdd.scenarios.test.cjs && node tests/gsdd.cross-runtime.test.cjs && node tests/phase.test.cjs && node tests/session-fingerprint.test.cjs",
11
+ "test:gsdd": "node tests/gsdd.init.test.cjs && node tests/gsdd.models.test.cjs && node tests/gsdd.consumer-ceremony.test.cjs && node tests/gsdd.manifest.test.cjs && node tests/gsdd.plan.adapters.test.cjs && node tests/gsdd.audit-milestone.test.cjs && node tests/gsdd.invariants.test.cjs && node tests/gsdd.guards.test.cjs && node tests/gsdd.health.test.cjs && node tests/gsdd.scenarios.test.cjs && node tests/gsdd.cross-runtime.test.cjs && node tests/gsdd.control-map.test.cjs && node tests/phase.test.cjs && node tests/session-fingerprint.test.cjs",
12
12
  "prepublishOnly": "node -e \"const ok=process.env.GITHUB_ACTIONS==='true'&&process.env.GITHUB_REF_NAME==='main'&&process.env.GITHUB_WORKFLOW==='Release'; if(!ok){console.error('Refusing to publish gsdd-cli outside the GitHub Actions Release workflow on main.'); process.exit(1)}\""
13
13
  },
14
14
  "devDependencies": {