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.
- package/README.md +3 -1
- package/bin/gsdd.mjs +4 -4
- package/bin/lib/closeout-report.mjs +292 -0
- package/bin/lib/control-map.mjs +612 -15
- package/bin/lib/health.mjs +46 -29
- package/bin/lib/init-runtime.mjs +8 -1
- package/bin/lib/lifecycle-preflight.mjs +58 -1
- package/bin/lib/phase.mjs +188 -41
- package/bin/lib/rendering.mjs +15 -1
- package/distilled/DESIGN.md +17 -9
- package/distilled/README.md +3 -1
- package/distilled/workflows/verify.md +1 -1
- package/package.json +2 -2
package/bin/lib/control-map.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// control-map.mjs - computed-first workspace/worktree control map
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
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:
|
|
415
|
+
path: resolved.label,
|
|
280
416
|
exists: false,
|
|
281
417
|
valid: false,
|
|
282
418
|
errors: [{
|
|
283
419
|
code: 'annotations_path_outside_workspace',
|
|
284
|
-
path:
|
|
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:
|
|
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:
|
|
439
|
+
path: resolved.label,
|
|
304
440
|
exists: true,
|
|
305
441
|
valid: false,
|
|
306
|
-
errors: [{ code: 'invalid_annotations_json', path:
|
|
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:
|
|
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: `${
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
1212
|
+
console.error(CONTROL_MAP_USAGE);
|
|
616
1213
|
process.exitCode = 1;
|
|
617
1214
|
return;
|
|
618
1215
|
}
|