gsdd-cli 0.24.0 → 0.25.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.
@@ -1,7 +1,7 @@
1
1
  // control-map.mjs - computed-first workspace/worktree control map
2
2
 
3
- import { existsSync, readFileSync, readdirSync } from 'fs';
4
- import { isAbsolute, join, relative, resolve } from 'path';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'path';
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
5
5
  import { spawnSync } from 'child_process';
6
6
  import { output, parseFlagValue } from './cli-utils.mjs';
7
7
  import { evaluateLifecycleState } from './lifecycle-state.mjs';
@@ -11,6 +11,10 @@ import { resolveWorkspaceContext } from './workspace-root.mjs';
11
11
  const DEFAULT_ANNOTATIONS_RELATIVE_PATH = '.planning/.local/control-map.annotations.json';
12
12
  const MAX_DIRTY_BUCKET_ENTRIES = 200;
13
13
  const CLEANUP_STATES = Object.freeze(['active', 'paused', 'merged', 'abandoned', 'superseded', 'cleanup_deferred']);
14
+ const ACTIVE_ANNOTATION_STATES = Object.freeze(['active', 'paused', 'cleanup_deferred']);
15
+ const CONTROL_MAP_USAGE = 'Usage: gsdd control-map [--json] [--with-ignored] [--annotations <path>]';
16
+ const CONTROL_MAP_ANNOTATE_SET_USAGE = 'Usage: gsdd control-map annotate set [--id <id>] [--path <worktree>] --write-set <paths> [--owner <runtime>] [--scope <text>] [--cleanup-state <state>] [--next-step <text>] [--refresh] [--annotations <path>]';
17
+ const CONTROL_MAP_ANNOTATE_CLEAR_USAGE = 'Usage: gsdd control-map annotate clear (--id <id> | --path <worktree>) [--annotations <path>]';
14
18
  const AUTHORITY_ORDER = Object.freeze([
15
19
  'repo_truth',
16
20
  'planning_artifacts',
@@ -65,6 +69,37 @@ function normalizeSlashes(value) {
65
69
  return String(value || '').replace(/\\/g, '/');
66
70
  }
67
71
 
72
+ function trimPathSlashes(value) {
73
+ const normalized = normalizeSlashes(value).trim().replace(/^\.\/+/, '');
74
+ if (!normalized || normalized === '.') return '.';
75
+ return normalized.replace(/\/+$/g, '');
76
+ }
77
+
78
+ function normalizeRepoPath(rawPath, worktreePath = null) {
79
+ const raw = normalizeSlashes(rawPath).trim();
80
+ if (!raw) return null;
81
+ const absolute = isAbsolute(raw) ? resolve(raw) : null;
82
+ if (absolute && worktreePath && isInsideOrSame(worktreePath, absolute)) {
83
+ return trimPathSlashes(relative(resolve(worktreePath), absolute));
84
+ }
85
+ return trimPathSlashes(raw);
86
+ }
87
+
88
+ function pathsIntersect(leftPath, rightPath) {
89
+ const left = trimPathSlashes(leftPath);
90
+ const right = trimPathSlashes(rightPath);
91
+ if (!left || !right) return false;
92
+ if (left === '.' || right === '.') return true;
93
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
94
+ }
95
+
96
+ function trackedStatusEntryPaths(entry) {
97
+ const payload = String(entry || '').slice(3).trim();
98
+ if (!payload) return [];
99
+ if (payload.includes(' -> ')) return payload.split(' -> ').map((part) => part.trim()).filter(Boolean);
100
+ return [payload];
101
+ }
102
+
68
103
  function workspacePathLabel(workspaceRoot, targetPath) {
69
104
  const absolute = resolve(targetPath);
70
105
  const rel = relative(workspaceRoot, absolute);
@@ -126,6 +161,97 @@ function parseAheadBehind(branchLine) {
126
161
  };
127
162
  }
128
163
 
164
+ function dirtyRepoPathEntries(worktree) {
165
+ const entries = [];
166
+ for (const entry of worktree.dirty.tracked || []) {
167
+ for (const repoPath of trackedStatusEntryPaths(entry)) {
168
+ const normalized = normalizeRepoPath(repoPath, worktree.path);
169
+ if (!normalized) continue;
170
+ entries.push({
171
+ worktree_id: worktree.id,
172
+ worktree_path: worktree.path,
173
+ kind: 'tracked',
174
+ repo_path: normalized,
175
+ });
176
+ }
177
+ }
178
+ for (const entry of worktree.dirty.untracked || []) {
179
+ const normalized = normalizeRepoPath(entry, worktree.path);
180
+ if (!normalized) continue;
181
+ entries.push({
182
+ worktree_id: worktree.id,
183
+ worktree_path: worktree.path,
184
+ kind: 'untracked',
185
+ repo_path: normalized,
186
+ });
187
+ }
188
+ return entries;
189
+ }
190
+
191
+ function annotationWriteEntries(worktrees, annotations) {
192
+ const byPath = new Map(worktrees.map((entry) => [normalizeSlashes(resolve(entry.path)), entry]));
193
+ const entries = [];
194
+ for (const annotation of annotations.worktrees || []) {
195
+ if (!ACTIVE_ANNOTATION_STATES.includes(annotation.cleanup_state)) continue;
196
+ if (!annotation.normalized_path || !Array.isArray(annotation.write_set) || annotation.write_set.length === 0) continue;
197
+ const worktree = byPath.get(normalizeSlashes(resolve(annotation.normalized_path)));
198
+ if (!worktree) continue;
199
+ for (const rawPath of annotation.write_set) {
200
+ if (typeof rawPath !== 'string') continue;
201
+ const repoPath = normalizeRepoPath(rawPath, worktree.path);
202
+ if (!repoPath) continue;
203
+ entries.push({
204
+ annotation_id: annotation.id,
205
+ worktree_id: worktree.id,
206
+ worktree_path: worktree.path,
207
+ raw_path: normalizeSlashes(rawPath),
208
+ repo_path: repoPath,
209
+ cleanup_state: annotation.cleanup_state,
210
+ });
211
+ }
212
+ }
213
+ return entries;
214
+ }
215
+
216
+ function findWriteSetOverlaps(writeEntries) {
217
+ const overlaps = [];
218
+ for (let leftIndex = 0; leftIndex < writeEntries.length; leftIndex += 1) {
219
+ for (let rightIndex = leftIndex + 1; rightIndex < writeEntries.length; rightIndex += 1) {
220
+ const left = writeEntries[leftIndex];
221
+ const right = writeEntries[rightIndex];
222
+ if (left.annotation_id === right.annotation_id) continue;
223
+ if (!pathsIntersect(left.repo_path, right.repo_path)) continue;
224
+ overlaps.push({
225
+ left_annotation_id: left.annotation_id,
226
+ left_worktree_id: left.worktree_id,
227
+ left_path: left.repo_path,
228
+ right_annotation_id: right.annotation_id,
229
+ right_worktree_id: right.worktree_id,
230
+ right_path: right.repo_path,
231
+ });
232
+ }
233
+ }
234
+ return overlaps;
235
+ }
236
+
237
+ function findDirtyWriteSetOverlaps(writeEntries, dirtyEntries) {
238
+ const overlaps = [];
239
+ for (const writeEntry of writeEntries) {
240
+ for (const dirtyEntry of dirtyEntries) {
241
+ if (!pathsIntersect(writeEntry.repo_path, dirtyEntry.repo_path)) continue;
242
+ overlaps.push({
243
+ annotation_id: writeEntry.annotation_id,
244
+ annotated_worktree_id: writeEntry.worktree_id,
245
+ write_path: writeEntry.repo_path,
246
+ dirty_worktree_id: dirtyEntry.worktree_id,
247
+ dirty_path: dirtyEntry.repo_path,
248
+ dirty_kind: dirtyEntry.kind,
249
+ });
250
+ }
251
+ }
252
+ return overlaps;
253
+ }
254
+
129
255
  function readGitWorktree(pathValue, workspaceRoot, { includeIgnoredPaths = false, includeIgnoredCount = false } = {}) {
130
256
  const status = runGit(pathValue, [
131
257
  'status',
@@ -270,18 +396,28 @@ function normalizeAnnotationPath(workspaceRoot, annotation) {
270
396
  return normalizeSlashes(resolve(workspaceRoot, raw));
271
397
  }
272
398
 
273
- function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
399
+ function resolveAnnotationFilePath(workspaceRoot, planningDir, annotationPathArg = null) {
274
400
  const annotationPath = annotationPathArg
275
401
  ? resolve(workspaceRoot, annotationPathArg)
276
402
  : join(planningDir, '.local', 'control-map.annotations.json');
277
- if (!isInsideOrSame(workspaceRoot, annotationPath)) {
403
+ return {
404
+ path: annotationPath,
405
+ label: workspacePathLabel(workspaceRoot, annotationPath),
406
+ inside_workspace: isInsideOrSame(workspaceRoot, annotationPath),
407
+ };
408
+ }
409
+
410
+ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
411
+ const resolved = resolveAnnotationFilePath(workspaceRoot, planningDir, annotationPathArg);
412
+ const annotationPath = resolved.path;
413
+ if (!resolved.inside_workspace) {
278
414
  return {
279
- path: workspacePathLabel(workspaceRoot, annotationPath),
415
+ path: resolved.label,
280
416
  exists: false,
281
417
  valid: false,
282
418
  errors: [{
283
419
  code: 'annotations_path_outside_workspace',
284
- path: workspacePathLabel(workspaceRoot, annotationPath),
420
+ path: resolved.label,
285
421
  message: 'Control-map annotations path must stay inside the workspace.',
286
422
  }],
287
423
  worktrees: [],
@@ -289,7 +425,7 @@ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
289
425
  }
290
426
  if (!existsSync(annotationPath)) {
291
427
  return {
292
- path: workspacePathLabel(workspaceRoot, annotationPath),
428
+ path: resolved.label,
293
429
  exists: false,
294
430
  valid: true,
295
431
  errors: [],
@@ -300,10 +436,10 @@ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
300
436
  const parsed = safeReadJson(annotationPath);
301
437
  if (!parsed.ok) {
302
438
  return {
303
- path: workspacePathLabel(workspaceRoot, annotationPath),
439
+ path: resolved.label,
304
440
  exists: true,
305
441
  valid: false,
306
- errors: [{ code: 'invalid_annotations_json', path: workspacePathLabel(workspaceRoot, annotationPath), message: parsed.error }],
442
+ errors: [{ code: 'invalid_annotations_json', path: resolved.label, message: parsed.error }],
307
443
  worktrees: [],
308
444
  };
309
445
  }
@@ -315,10 +451,10 @@ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
315
451
  : [];
316
452
  const errors = [];
317
453
  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.' });
454
+ errors.push({ code: 'invalid_annotations_shape', path: resolved.label, message: 'Control-map annotations must be a JSON object.' });
319
455
  }
320
456
  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.' });
457
+ errors.push({ code: 'invalid_worktrees_annotations', path: `${resolved.label}.worktrees`, message: 'worktrees must be an array.' });
322
458
  }
323
459
 
324
460
  const worktrees = rawWorktrees.filter((entry, index) => {
@@ -353,7 +489,7 @@ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
353
489
  }
354
490
 
355
491
  return {
356
- path: workspacePathLabel(workspaceRoot, annotationPath),
492
+ path: resolved.label,
357
493
  exists: true,
358
494
  valid: errors.length === 0,
359
495
  errors,
@@ -361,6 +497,383 @@ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) {
361
497
  };
362
498
  }
363
499
 
500
+ function parseAnnotationMutationFlags(args, usage, { valueFlags, booleanFlags = [] }) {
501
+ const allowedValueFlags = new Set(valueFlags);
502
+ const allowedBooleanFlags = new Set(booleanFlags);
503
+ const values = new Map();
504
+ const listValues = new Map([['--write-set', []]]);
505
+ const booleans = new Set();
506
+ const unknown = [];
507
+
508
+ for (let index = 0; index < args.length; index += 1) {
509
+ const arg = args[index];
510
+ if (allowedBooleanFlags.has(arg)) {
511
+ booleans.add(arg);
512
+ continue;
513
+ }
514
+ if (!allowedValueFlags.has(arg)) {
515
+ unknown.push(arg);
516
+ continue;
517
+ }
518
+
519
+ const value = args[index + 1];
520
+ if (!value || value.startsWith('--')) {
521
+ return { valid: false, usage, error: `Missing value for ${arg}.` };
522
+ }
523
+ if (arg === '--write-set') listValues.get(arg).push(value);
524
+ else values.set(arg, value);
525
+ index += 1;
526
+ }
527
+
528
+ if (unknown.length > 0) {
529
+ return { valid: false, usage, error: `Unsupported argument(s): ${unknown.join(', ')}` };
530
+ }
531
+
532
+ return {
533
+ valid: true,
534
+ values,
535
+ listValues,
536
+ booleans,
537
+ };
538
+ }
539
+
540
+ function parseWriteSet(values, worktreePath) {
541
+ const entries = [];
542
+ for (const rawValue of values || []) {
543
+ for (const part of String(rawValue).split(',')) {
544
+ const normalized = normalizeRepoPath(part, worktreePath);
545
+ if (normalized && !entries.includes(normalized)) entries.push(normalized);
546
+ }
547
+ }
548
+ return entries;
549
+ }
550
+
551
+ function annotationPathLabelForWrite(workspaceRoot, worktreePath) {
552
+ return workspacePathLabel(workspaceRoot, worktreePath);
553
+ }
554
+
555
+ function annotationIdForWorktree(worktree) {
556
+ if (worktree.id === '.') return 'canonical';
557
+ return worktree.id;
558
+ }
559
+
560
+ function serializeAnnotationEntry(workspaceRoot, entry) {
561
+ const serialized = {
562
+ id: entry.id,
563
+ path: entry.path || annotationPathLabelForWrite(workspaceRoot, entry.normalized_path),
564
+ cleanup_state: entry.cleanup_state || 'active',
565
+ };
566
+ if (entry.runtime_owner) serialized.runtime_owner = entry.runtime_owner;
567
+ if (entry.branch) serialized.branch = entry.branch;
568
+ if (entry.last_known_head) serialized.last_known_head = entry.last_known_head;
569
+ if (entry.intended_scope) serialized.intended_scope = entry.intended_scope;
570
+ if (Array.isArray(entry.write_set) && entry.write_set.length > 0) serialized.write_set = entry.write_set;
571
+ if (entry.next_step) serialized.next_step = entry.next_step;
572
+ if (entry.updated_at) serialized.updated_at = entry.updated_at;
573
+ return serialized;
574
+ }
575
+
576
+ function writeAnnotationsDocument(workspaceRoot, annotationFile, worktrees) {
577
+ mkdirSync(dirname(annotationFile.path), { recursive: true });
578
+ const document = {
579
+ schema_version: 1,
580
+ worktrees: worktrees.map((entry) => serializeAnnotationEntry(workspaceRoot, entry)),
581
+ };
582
+ writeFileSync(annotationFile.path, `${JSON.stringify(document, null, 2)}\n`);
583
+ }
584
+
585
+ function buildMutationContext(context, annotationPathArg) {
586
+ const annotationFile = resolveAnnotationFilePath(context.workspaceRoot, context.planningDir, annotationPathArg);
587
+ if (!annotationFile.inside_workspace) {
588
+ return {
589
+ ok: false,
590
+ result: {
591
+ status: 'blocked',
592
+ reason: 'annotations_path_outside_workspace',
593
+ annotations_path: annotationFile.label,
594
+ message: 'Control-map annotations path must stay inside the workspace.',
595
+ },
596
+ };
597
+ }
598
+
599
+ const annotations = loadAnnotations(context.workspaceRoot, context.planningDir, annotationPathArg);
600
+ if (!annotations.valid) {
601
+ return {
602
+ ok: false,
603
+ result: {
604
+ status: 'blocked',
605
+ reason: 'invalid_annotations',
606
+ annotations_path: annotations.path,
607
+ errors: annotations.errors,
608
+ },
609
+ };
610
+ }
611
+
612
+ const discovered = discoverGitWorktrees(context.workspaceRoot);
613
+ return {
614
+ ok: true,
615
+ annotationFile,
616
+ annotations,
617
+ worktrees: discovered.worktrees,
618
+ };
619
+ }
620
+
621
+ function findWorktreeByPath(worktrees, pathValue) {
622
+ const normalized = normalizeSlashes(resolve(pathValue));
623
+ return worktrees.find((worktree) => normalizeSlashes(resolve(worktree.path)) === normalized) || null;
624
+ }
625
+
626
+ function findAnnotationIndex(annotations, { id = null, normalizedPath = null } = {}) {
627
+ if (id) {
628
+ const byId = annotations.worktrees.findIndex((entry) => entry.id === id);
629
+ if (byId !== -1) return byId;
630
+ }
631
+ if (normalizedPath) {
632
+ return annotations.worktrees.findIndex((entry) => (
633
+ entry.normalized_path && normalizeSlashes(resolve(entry.normalized_path)) === normalizeSlashes(resolve(normalizedPath))
634
+ ));
635
+ }
636
+ return -1;
637
+ }
638
+
639
+ function staleAnnotationIssues(annotation, worktree) {
640
+ const issues = [];
641
+ if (annotation.branch && worktree.branch && annotation.branch !== worktree.branch) {
642
+ issues.push({
643
+ code: 'branch_mismatch',
644
+ saved: annotation.branch,
645
+ live: worktree.branch,
646
+ });
647
+ }
648
+ if (annotation.last_known_head && worktree.head && annotation.last_known_head !== worktree.head) {
649
+ issues.push({
650
+ code: 'head_mismatch',
651
+ saved: annotation.last_known_head,
652
+ live: worktree.head,
653
+ });
654
+ }
655
+ return issues;
656
+ }
657
+
658
+ function annotationMutationSummary(annotation) {
659
+ return {
660
+ id: annotation.id,
661
+ path: annotation.path,
662
+ runtime_owner: annotation.runtime_owner || null,
663
+ branch: annotation.branch || null,
664
+ last_known_head: annotation.last_known_head || null,
665
+ intended_scope: annotation.intended_scope || null,
666
+ write_set: annotation.write_set || [],
667
+ cleanup_state: annotation.cleanup_state || 'active',
668
+ next_step: annotation.next_step || null,
669
+ updated_at: annotation.updated_at || null,
670
+ };
671
+ }
672
+
673
+ function setAnnotation(context, args) {
674
+ const parsed = parseAnnotationMutationFlags(args, CONTROL_MAP_ANNOTATE_SET_USAGE, {
675
+ valueFlags: [
676
+ '--annotations',
677
+ '--cleanup-state',
678
+ '--id',
679
+ '--next-step',
680
+ '--owner',
681
+ '--path',
682
+ '--runtime-owner',
683
+ '--scope',
684
+ '--write-set',
685
+ ],
686
+ booleanFlags: ['--refresh'],
687
+ });
688
+ if (!parsed.valid) return { ok: false, result: { status: 'blocked', reason: 'invalid_arguments', message: parsed.error, usage: parsed.usage } };
689
+
690
+ const annotationPathArg = parsed.values.get('--annotations') || null;
691
+ const mutation = buildMutationContext(context, annotationPathArg);
692
+ if (!mutation.ok) return mutation;
693
+
694
+ const explicitId = parsed.values.get('--id') || null;
695
+ const pathValue = parsed.values.get('--path') || '.';
696
+ const targetPath = resolve(context.workspaceRoot, pathValue);
697
+ const worktree = findWorktreeByPath(mutation.worktrees, targetPath);
698
+ if (!worktree || !worktree.git_valid) {
699
+ return {
700
+ ok: false,
701
+ result: {
702
+ status: 'blocked',
703
+ reason: 'worktree_not_found',
704
+ message: `No live git worktree found for ${pathValue}.`,
705
+ path: workspacePathLabel(context.workspaceRoot, targetPath),
706
+ },
707
+ };
708
+ }
709
+
710
+ const writeSet = parseWriteSet(parsed.listValues.get('--write-set'), worktree.path);
711
+ if (writeSet.length === 0) {
712
+ return {
713
+ ok: false,
714
+ result: {
715
+ status: 'blocked',
716
+ reason: 'missing_write_set',
717
+ message: 'Annotation set requires at least one --write-set value.',
718
+ usage: CONTROL_MAP_ANNOTATE_SET_USAGE,
719
+ },
720
+ };
721
+ }
722
+
723
+ const normalizedPath = normalizeSlashes(resolve(worktree.path));
724
+ const existingIndex = findAnnotationIndex(mutation.annotations, { id: explicitId, normalizedPath });
725
+ const existing = existingIndex === -1 ? null : mutation.annotations.worktrees[existingIndex];
726
+
727
+ const cleanupState = parsed.values.get('--cleanup-state') || existing?.cleanup_state || 'active';
728
+ if (!CLEANUP_STATES.includes(cleanupState)) {
729
+ return {
730
+ ok: false,
731
+ result: {
732
+ status: 'blocked',
733
+ reason: 'invalid_cleanup_state',
734
+ message: `Unsupported cleanup_state: ${cleanupState}`,
735
+ supported_cleanup_states: CLEANUP_STATES,
736
+ },
737
+ };
738
+ }
739
+
740
+ const staleIssues = existing ? staleAnnotationIssues(existing, worktree) : [];
741
+ const refresh = parsed.booleans.has('--refresh');
742
+ if (staleIssues.length > 0 && !refresh) {
743
+ return {
744
+ ok: false,
745
+ result: {
746
+ operation: 'control-map annotate set',
747
+ status: 'blocked',
748
+ reason: 'stale_annotation',
749
+ annotations_path: mutation.annotationFile.label,
750
+ annotation_id: existing.id,
751
+ stale_issues: staleIssues,
752
+ message: 'Existing annotation is stale against live worktree truth; rerun with --refresh to update it or use annotate clear to remove it.',
753
+ },
754
+ };
755
+ }
756
+
757
+ const now = new Date().toISOString();
758
+ const annotation = {
759
+ id: explicitId || existing?.id || annotationIdForWorktree(worktree),
760
+ path: annotationPathLabelForWrite(context.workspaceRoot, worktree.path),
761
+ runtime_owner: parsed.values.get('--runtime-owner') || parsed.values.get('--owner') || existing?.runtime_owner || null,
762
+ branch: worktree.branch,
763
+ last_known_head: worktree.head,
764
+ intended_scope: parsed.values.get('--scope') || existing?.intended_scope || null,
765
+ write_set: writeSet,
766
+ cleanup_state: cleanupState,
767
+ next_step: parsed.values.get('--next-step') || existing?.next_step || null,
768
+ updated_at: now,
769
+ };
770
+ const nextWorktrees = mutation.annotations.worktrees.slice();
771
+ if (existingIndex === -1) nextWorktrees.push(annotation);
772
+ else nextWorktrees[existingIndex] = annotation;
773
+ writeAnnotationsDocument(context.workspaceRoot, mutation.annotationFile, nextWorktrees);
774
+
775
+ return {
776
+ ok: true,
777
+ result: {
778
+ operation: 'control-map annotate set',
779
+ changed: true,
780
+ status: existing ? (refresh && staleIssues.length > 0 ? 'refreshed' : 'updated') : 'created',
781
+ annotations_path: mutation.annotationFile.label,
782
+ annotation: annotationMutationSummary(annotation),
783
+ stale_check: {
784
+ status: staleIssues.length > 0 ? 'refreshed' : 'passed',
785
+ issues: staleIssues,
786
+ },
787
+ },
788
+ };
789
+ }
790
+
791
+ function clearAnnotation(context, args) {
792
+ const parsed = parseAnnotationMutationFlags(args, CONTROL_MAP_ANNOTATE_CLEAR_USAGE, {
793
+ valueFlags: ['--annotations', '--id', '--path'],
794
+ });
795
+ if (!parsed.valid) return { ok: false, result: { status: 'blocked', reason: 'invalid_arguments', message: parsed.error, usage: parsed.usage } };
796
+
797
+ const id = parsed.values.get('--id') || null;
798
+ const pathValue = parsed.values.get('--path') || null;
799
+ if (!id && !pathValue) {
800
+ return {
801
+ ok: false,
802
+ result: {
803
+ status: 'blocked',
804
+ reason: 'missing_selector',
805
+ message: 'Annotation clear requires --id or --path.',
806
+ usage: CONTROL_MAP_ANNOTATE_CLEAR_USAGE,
807
+ },
808
+ };
809
+ }
810
+
811
+ const annotationPathArg = parsed.values.get('--annotations') || null;
812
+ const mutation = buildMutationContext(context, annotationPathArg);
813
+ if (!mutation.ok) return mutation;
814
+ if (!mutation.annotations.exists) {
815
+ return {
816
+ ok: true,
817
+ result: {
818
+ operation: 'control-map annotate clear',
819
+ changed: false,
820
+ status: 'missing',
821
+ annotations_path: mutation.annotationFile.label,
822
+ },
823
+ };
824
+ }
825
+
826
+ const normalizedPath = pathValue ? normalizeSlashes(resolve(context.workspaceRoot, pathValue)) : null;
827
+ const existingIndex = findAnnotationIndex(mutation.annotations, { id, normalizedPath });
828
+ if (existingIndex === -1) {
829
+ return {
830
+ ok: true,
831
+ result: {
832
+ operation: 'control-map annotate clear',
833
+ changed: false,
834
+ status: 'not_found',
835
+ annotations_path: mutation.annotationFile.label,
836
+ selector: id ? { id } : { path: workspacePathLabel(context.workspaceRoot, normalizedPath) },
837
+ },
838
+ };
839
+ }
840
+
841
+ const removed = mutation.annotations.worktrees[existingIndex];
842
+ const nextWorktrees = mutation.annotations.worktrees.filter((_, index) => index !== existingIndex);
843
+ writeAnnotationsDocument(context.workspaceRoot, mutation.annotationFile, nextWorktrees);
844
+
845
+ return {
846
+ ok: true,
847
+ result: {
848
+ operation: 'control-map annotate clear',
849
+ changed: true,
850
+ status: 'cleared',
851
+ annotations_path: mutation.annotationFile.label,
852
+ annotation: annotationMutationSummary(removed),
853
+ },
854
+ };
855
+ }
856
+
857
+ function cmdControlMapAnnotate(context, args) {
858
+ const [operation, ...operationArgs] = args;
859
+ let result;
860
+ if (operation === 'set') result = setAnnotation(context, operationArgs);
861
+ else if (operation === 'clear') result = clearAnnotation(context, operationArgs);
862
+ else {
863
+ result = {
864
+ ok: false,
865
+ result: {
866
+ status: 'blocked',
867
+ reason: 'invalid_operation',
868
+ message: 'Usage: gsdd control-map annotate <set|clear> ...',
869
+ },
870
+ };
871
+ }
872
+
873
+ output(result.result);
874
+ if (!result.ok) process.exitCode = 1;
875
+ }
876
+
364
877
  function reconcileAnnotations(worktrees, annotations) {
365
878
  const warnings = [];
366
879
  const byPath = new Map(worktrees.map((entry) => [normalizeSlashes(resolve(entry.path)), entry]));
@@ -432,14 +945,50 @@ function classifyWorkflowState(planningDir) {
432
945
  };
433
946
  }
434
947
 
435
- function buildRisks({ canonical, worktrees, annotations, runtimeDirs, workflowState, gitErrors }) {
948
+ function addBranchStateRisks(risks, worktree, { canonical = false } = {}) {
949
+ const ahead = worktree.ahead_behind?.ahead;
950
+ const behind = worktree.ahead_behind?.behind;
951
+ if (behind === null || behind === undefined || behind <= 0) return;
952
+ const prefix = canonical ? 'canonical' : 'worktree';
953
+ const label = canonical ? 'Canonical worktree' : `Worktree ${worktree.id}`;
954
+ const branchDetails = {
955
+ worktree_id: canonical ? undefined : worktree.id,
956
+ branch: worktree.branch,
957
+ ahead,
958
+ behind,
959
+ };
960
+
961
+ if (ahead > 0) {
962
+ risks.push({
963
+ code: `${prefix}_branch_diverged_upstream`,
964
+ severity: 'warn',
965
+ ...branchDetails,
966
+ message: `${label} has diverged from its upstream (${ahead} ahead, ${behind} behind).`,
967
+ });
968
+ } else {
969
+ risks.push({
970
+ code: `${prefix}_branch_behind_upstream`,
971
+ severity: 'warn',
972
+ ...branchDetails,
973
+ message: `${label} is behind its upstream by ${behind} commit(s).`,
974
+ });
975
+ }
976
+ }
977
+
978
+ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtimeDirs, workflowState, gitErrors }) {
436
979
  const risks = [];
980
+ const writeEntries = annotationWriteEntries(worktrees, rawAnnotations || { worktrees: [] });
981
+ const dirtyEntries = worktrees.flatMap((worktree) => dirtyRepoPathEntries(worktree));
982
+ const writeSetOverlaps = findWriteSetOverlaps(writeEntries);
983
+ const dirtyWriteSetOverlaps = findDirtyWriteSetOverlaps(writeEntries, dirtyEntries);
984
+
437
985
  for (const error of gitErrors) {
438
986
  risks.push({ code: error.code, severity: 'warn', message: error.message });
439
987
  }
440
988
  if (!canonical.git_valid) {
441
989
  risks.push({ code: 'canonical_git_invalid', severity: 'warn', message: `Canonical worktree git status failed: ${canonical.status_error || 'unknown error'}` });
442
990
  }
991
+ addBranchStateRisks(risks, canonical, { canonical: true });
443
992
  if (canonical.dirty.counts.tracked > 0 || canonical.dirty.counts.untracked > 0) {
444
993
  risks.push({
445
994
  code: 'canonical_dirty',
@@ -447,6 +996,16 @@ function buildRisks({ canonical, worktrees, annotations, runtimeDirs, workflowSt
447
996
  message: `Canonical worktree has tracked/untracked changes (${canonical.dirty.counts.tracked} tracked, ${canonical.dirty.counts.untracked} untracked).`,
448
997
  });
449
998
  }
999
+ if (canonical.dirty.counts.tracked > 0 && (canonical.ahead_behind?.behind || 0) > 0) {
1000
+ risks.push({
1001
+ code: 'canonical_dirty_behind_upstream',
1002
+ severity: 'block',
1003
+ branch: canonical.branch,
1004
+ ahead: canonical.ahead_behind?.ahead,
1005
+ behind: canonical.ahead_behind?.behind,
1006
+ message: `Canonical worktree has tracked changes while behind upstream by ${canonical.ahead_behind.behind} commit(s).`,
1007
+ });
1008
+ }
450
1009
  if (canonical.dirty.counts.ignored > 0) {
451
1010
  risks.push({
452
1011
  code: 'ignored_local_surfaces_present',
@@ -458,6 +1017,15 @@ function buildRisks({ canonical, worktrees, annotations, runtimeDirs, workflowSt
458
1017
  if (!worktree.git_valid) {
459
1018
  risks.push({ code: 'worktree_git_invalid', severity: 'warn', worktree_id: worktree.id, message: `Worktree ${worktree.id} could not be inspected by git.` });
460
1019
  }
1020
+ addBranchStateRisks(risks, worktree);
1021
+ if (worktree.detached) {
1022
+ risks.push({
1023
+ code: 'detached_candidate_worktree',
1024
+ severity: 'warn',
1025
+ worktree_id: worktree.id,
1026
+ message: `Worktree ${worktree.id} is detached; classify its intent before treating it as an execution surface.`,
1027
+ });
1028
+ }
461
1029
  if (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0) {
462
1030
  risks.push({
463
1031
  code: 'sibling_worktree_dirty',
@@ -475,6 +1043,24 @@ function buildRisks({ canonical, worktrees, annotations, runtimeDirs, workflowSt
475
1043
  });
476
1044
  }
477
1045
  }
1046
+ if (writeSetOverlaps.length > 0) {
1047
+ risks.push({
1048
+ code: 'write_set_overlap',
1049
+ severity: 'block',
1050
+ message: `Active control-map annotations have ${writeSetOverlaps.length} concrete write-set overlap(s).`,
1051
+ overlaps: writeSetOverlaps.slice(0, MAX_DIRTY_BUCKET_ENTRIES),
1052
+ omitted_count: Math.max(0, writeSetOverlaps.length - MAX_DIRTY_BUCKET_ENTRIES),
1053
+ });
1054
+ }
1055
+ if (dirtyWriteSetOverlaps.length > 0) {
1056
+ risks.push({
1057
+ code: 'dirty_path_write_set_overlap',
1058
+ severity: 'block',
1059
+ message: `Live dirty paths overlap annotated write sets (${dirtyWriteSetOverlaps.length} overlap(s)).`,
1060
+ overlaps: dirtyWriteSetOverlaps.slice(0, MAX_DIRTY_BUCKET_ENTRIES),
1061
+ omitted_count: Math.max(0, dirtyWriteSetOverlaps.length - MAX_DIRTY_BUCKET_ENTRIES),
1062
+ });
1063
+ }
478
1064
  for (const warning of annotations.warnings || []) risks.push(warning);
479
1065
  for (const error of annotations.errors || []) {
480
1066
  risks.push({ code: error.code, severity: 'warn', message: error.message, path: error.path });
@@ -501,6 +1087,11 @@ function buildInterventions(risks) {
501
1087
  const codes = new Set(risks.map((risk) => risk.code));
502
1088
  const interventions = [];
503
1089
  if (codes.has('canonical_git_invalid')) interventions.push('Fix git/safe.directory access before mutating this checkout.');
1090
+ if (codes.has('write_set_overlap')) interventions.push('Resolve overlapping local annotation write sets before starting another owned-write workflow.');
1091
+ if (codes.has('dirty_path_write_set_overlap')) interventions.push('Checkpoint, commit, stash, or explicitly classify dirty paths that overlap annotated write sets before owned-write transitions.');
1092
+ if (codes.has('canonical_dirty_behind_upstream')) interventions.push('Sync the canonical branch or preserve dirty canonical work before mutating a stale checkout.');
1093
+ if (codes.has('canonical_branch_behind_upstream') || codes.has('canonical_branch_diverged_upstream') || codes.has('worktree_branch_behind_upstream') || codes.has('worktree_branch_diverged_upstream')) interventions.push('Review upstream divergence before relying on stale branch state for implementation decisions.');
1094
+ if (codes.has('detached_candidate_worktree')) interventions.push('Classify detached worktree intent before using it for execution or cleanup decisions.');
504
1095
  if (codes.has('canonical_dirty')) interventions.push('Checkpoint or classify canonical dirty work before planning, cleanup, merge, or broad execution.');
505
1096
  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
1097
  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.');
@@ -537,6 +1128,7 @@ export function buildControlMap({
537
1128
  canonical,
538
1129
  worktrees: reconciled.worktrees,
539
1130
  annotations: annotationReport,
1131
+ rawAnnotations: annotations,
540
1132
  runtimeDirs,
541
1133
  workflowState,
542
1134
  gitErrors: discovered.errors,
@@ -599,9 +1191,14 @@ export function cmdControlMap(...args) {
599
1191
  return;
600
1192
  }
601
1193
 
1194
+ if (context.args[0] === 'annotate') {
1195
+ cmdControlMapAnnotate(context, context.args.slice(1));
1196
+ return;
1197
+ }
1198
+
602
1199
  const annotations = parseFlagValue(context.args, '--annotations');
603
1200
  if (annotations.invalid) {
604
- console.error('Usage: gsdd control-map [--json] [--with-ignored] [--annotations <path>]');
1201
+ console.error(CONTROL_MAP_USAGE);
605
1202
  process.exitCode = 1;
606
1203
  return;
607
1204
  }
@@ -612,7 +1209,7 @@ export function cmdControlMap(...args) {
612
1209
  return true;
613
1210
  });
614
1211
  if (filteredArgs.length > 0) {
615
- console.error('Usage: gsdd control-map [--json] [--with-ignored] [--annotations <path>]');
1212
+ console.error(CONTROL_MAP_USAGE);
616
1213
  process.exitCode = 1;
617
1214
  return;
618
1215
  }