gsdd-cli 0.25.0 → 0.27.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.
@@ -36,7 +36,20 @@ ${delegateContent.trim()}
36
36
  `;
37
37
  }
38
38
 
39
- function renderClaudePlanSkill() {
39
+ function renderClaudePlanSkill({ portableContractPath = '.agents/skills/gsdd-plan/SKILL.md' } = {}) {
40
+ const contractSection = portableContractPath
41
+ ? `Portable contract:
42
+ - Read \`${portableContractPath}\` first. That file remains the canonical vendor-agnostic plan contract.
43
+ - Keep the portable contract honest: it defines the workflow, but it does not by itself prove fresh-context checker orchestration across runtimes.
44
+ - If the portable skill says plan is still a stub, treat that as a portability-status warning for the generic surface, not as a stop signal for this Claude-native adapter path.`
45
+ : `Workflow contract:
46
+ - This globally installed skill is the canonical Claude-native \`gsdd-plan\` workflow contract.
47
+ - Keep the workflow portable: use repo-local \`.planning/\` artifacts for project state and do not require repo-local \`.agents/skills/\` files to exist.
48
+ - Do not claim that other runtimes have the same behavior unless their own adapters explicitly implement and prove it.`;
49
+ const planningContract = portableContractPath
50
+ ? `\`${portableContractPath}\``
51
+ : 'this skill';
52
+
40
53
  return `---
41
54
  name: gsdd-plan
42
55
  description: Claude-native Phase planning with fresh-context plan checking for GSDD
@@ -45,10 +58,7 @@ argument-hint: [phase-number]
45
58
 
46
59
  You are the Claude-native \`/gsdd-plan\` skill for GSDD phase planning.
47
60
 
48
- Portable contract:
49
- - Read \`.agents/skills/gsdd-plan/SKILL.md\` first. That file remains the canonical vendor-agnostic plan contract.
50
- - Keep the portable contract honest: it defines the workflow, but it does not by itself prove fresh-context checker orchestration across runtimes.
51
- - If the portable skill says plan is still a stub, treat that as a portability-status warning for the generic surface, not as a stop signal for this Claude-native adapter path.
61
+ ${contractSection}
52
62
 
53
63
  Native Claude adapter rule:
54
64
  - This skill is the canonical Claude-native entry surface for \`/gsdd-plan\`.
@@ -66,7 +76,7 @@ Execution flow:
66
76
  d. The explorer runs a GSD-style interactive conversation with the user (gray areas, research, deep-dive questions, assumptions) and writes APPROACH.md.
67
77
  e. Before planning, confirm APPROACH.md records all canonical proof fields: \`alignment_status\`, \`alignment_method\`, \`user_confirmed_at\`, \`explicit_skip_approved\`, \`skip_scope\`, \`skip_rationale\`, and \`confirmed_decisions\`. For \`alignment_status: user_confirmed\`, \`confirmed_decisions\` must name the locked decisions and skip fields may be \`false\`/\`N/A\`; for \`alignment_status: approved_skip\`, \`explicit_skip_approved: true\`, \`skip_scope\`, and \`skip_rationale\` must be substantive. Agent-only "No questions needed" is not valid proof under \`workflow.discuss: true\`.
68
78
  f. Load APPROACH.md decisions as locked constraints alongside SPEC.md decisions.
69
- 4. Produce the initial phase plan according to \`.agents/skills/gsdd-plan/SKILL.md\`. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
79
+ 4. Produce the initial phase plan according to ${planningContract}. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
70
80
  5. If \`.planning/config.json\` has \`workflow.planCheck: false\`, stop after planner self-check and explicitly report reduced assurance. This only skips the independent checker; it does not skip the step 3 alignment-proof gate when \`workflow.discuss: true\`.
71
81
  6. If \`workflow.planCheck: true\`, invoke the native \`gsdd-plan-checker\` subagent with fresh context.
72
82
  7. Pass only explicit inputs to the checker:
@@ -107,14 +117,14 @@ Never return raw checker JSON without summarizing it.
107
117
  `;
108
118
  }
109
119
 
110
- function renderClaudePlanCommand() {
120
+ function renderClaudePlanCommand({ skillPath = '.claude/skills/gsdd-plan/SKILL.md' } = {}) {
111
121
  return `---
112
122
  description: Compatibility alias for the Claude-native \`/gsdd-plan\` skill
113
123
  argument-hint: [phase-number]
114
124
  allowed-tools: Read
115
125
  ---
116
126
 
117
- Read \`.claude/skills/gsdd-plan/SKILL.md\` and execute that skill as the canonical Claude-native \`/gsdd-plan\` entry.
127
+ Read \`${skillPath}\` and execute that skill as the canonical Claude-native \`/gsdd-plan\` entry.
118
128
 
119
129
  Rules:
120
130
  - Do NOT duplicate orchestration logic here.
@@ -145,7 +145,7 @@ ${delegateContent.trim()}
145
145
  `;
146
146
  }
147
147
 
148
- function renderOpenCodePlanCommand() {
148
+ function renderOpenCodePlanCommand({ skillPath = '.agents/skills/gsdd-plan/SKILL.md' } = {}) {
149
149
  return `---
150
150
  description: OpenCode-native phase planning with fresh-context plan checking for GSDD
151
151
  subtask: false
@@ -154,7 +154,7 @@ subtask: false
154
154
  You are the OpenCode-native \`/gsdd-plan\` command for GSDD phase planning.
155
155
 
156
156
  Portable contract:
157
- - Read \`.agents/skills/gsdd-plan/SKILL.md\` first. That file remains the canonical vendor-agnostic plan contract.
157
+ - Read \`${skillPath}\` first. That file remains the canonical vendor-agnostic plan contract.
158
158
  - Keep the portable contract honest: it defines the workflow, but it does not by itself prove fresh-context checker orchestration across runtimes.
159
159
  - If the portable skill says plan is still a stub, treat that as a portability-status warning for the generic surface, not as a stop signal for this OpenCode-native adapter path.
160
160
 
@@ -174,7 +174,7 @@ Execution flow:
174
174
  d. The explorer runs a GSD-style interactive conversation with the user (gray areas, research, deep-dive questions, assumptions) and writes APPROACH.md.
175
175
  e. Before planning, confirm APPROACH.md records all canonical proof fields: \`alignment_status\`, \`alignment_method\`, \`user_confirmed_at\`, \`explicit_skip_approved\`, \`skip_scope\`, \`skip_rationale\`, and \`confirmed_decisions\`. For \`alignment_status: user_confirmed\`, \`confirmed_decisions\` must name the locked decisions and skip fields may be \`false\`/\`N/A\`; for \`alignment_status: approved_skip\`, \`explicit_skip_approved: true\`, \`skip_scope\`, and \`skip_rationale\` must be substantive. Agent-only "No questions needed" is not valid proof under \`workflow.discuss: true\`.
176
176
  f. Load APPROACH.md decisions as locked constraints alongside SPEC.md decisions.
177
- 4. Produce the initial phase plan according to \`.agents/skills/gsdd-plan/SKILL.md\`. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
177
+ 4. Produce the initial phase plan according to \`${skillPath}\`. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
178
178
  5. If \`.planning/config.json\` has \`workflow.planCheck: false\`, stop after planner self-check and explicitly report reduced assurance. This only skips the independent checker; it does not skip the step 3 alignment-proof gate when \`workflow.discuss: true\`.
179
179
  6. If \`workflow.planCheck: true\`, invoke the hidden \`gsdd-plan-checker\` subagent with fresh context.
180
180
  7. Pass only explicit inputs to the checker:
package/bin/gsdd.mjs CHANGED
@@ -11,8 +11,9 @@ import {
11
11
  upsertBoundedBlock,
12
12
  getDelegateContent,
13
13
  } from './lib/rendering.mjs';
14
- import { loadProjectModelConfig, getRuntimeModelOverride, resolveRuntimeAgentModel, cmdModels } from './lib/models.mjs';
14
+ import { loadProjectModelConfig, getRuntimeModelOverride, resolveRuntimeAgentModel, cmdModels, cmdRigor } from './lib/models.mjs';
15
15
  import { createCmdInit, createCmdUpdate, cmdHelp } from './lib/init.mjs';
16
+ import { createCmdInstall } from './lib/global-install.mjs';
16
17
  import { cmdFindPhase, cmdVerify, cmdScaffold, cmdPhaseStatus } from './lib/phase.mjs';
17
18
  import { cmdFileOp } from './lib/file-ops.mjs';
18
19
  import { createCmdHealth } from './lib/health.mjs';
@@ -21,7 +22,9 @@ import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
21
22
  import { cmdUiProof } from './lib/ui-proof.mjs';
22
23
  import { cmdControlMap } from './lib/control-map.mjs';
23
24
  import { createCmdCloseoutReport } from './lib/closeout-report.mjs';
25
+ import { createCmdNext } from './lib/next.mjs';
24
26
  import { resolveWorkspaceContext } from './lib/workspace-root.mjs';
27
+ import { FRAMEWORK_VERSION, WORKFLOWS } from './lib/workflows.mjs';
25
28
  const __filename = fileURLToPath(import.meta.url);
26
29
  const __dirname = dirname(__filename);
27
30
  const DISTILLED_DIR = join(__dirname, '..', 'distilled');
@@ -31,33 +34,6 @@ const IS_MAIN = process.argv[1] ? realpathSync(process.argv[1]) === realpathSync
31
34
 
32
35
  const [,, command, ...args] = process.argv;
33
36
 
34
- function defineWorkflow({ mutatesArtifacts = true, ...workflow }) {
35
- return {
36
- ...workflow,
37
- mutatesArtifacts,
38
- agent: mutatesArtifacts ? 'Code' : 'Plan',
39
- opencodeType: mutatesArtifacts ? 'edit' : 'plan',
40
- };
41
- }
42
-
43
- const WORKFLOWS = [
44
- defineWorkflow({ name: 'gsdd-new-project', workflow: 'new-project.md', description: 'New project - questioning, codebase audit, research, spec, roadmap' }),
45
- defineWorkflow({ name: 'gsdd-map-codebase', workflow: 'map-codebase.md', description: 'Map or refresh codebase - 4 parallel mappers, staleness check, secrets scan' }),
46
- defineWorkflow({ name: 'gsdd-plan', workflow: 'plan.md', description: 'Plan a phase - research check, backward planning, task creation' }),
47
- defineWorkflow({ name: 'gsdd-execute', workflow: 'execute.md', description: 'Execute a phase plan - implement tasks, verify changes, follow repo git conventions' }),
48
- defineWorkflow({ name: 'gsdd-verify', workflow: 'verify.md', description: 'Verify a completed phase - 3-level checks, anti-pattern scan' }),
49
- defineWorkflow({ name: 'gsdd-verify-work', workflow: 'verify-work.md', description: 'Conversational UAT testing - validate user-facing behavior with structured gap tracking' }),
50
- defineWorkflow({ name: 'gsdd-audit-milestone', workflow: 'audit-milestone.md', description: 'Audit a completed milestone - cross-phase integration, requirements coverage, E2E flows' }),
51
- defineWorkflow({ name: 'gsdd-complete-milestone', workflow: 'complete-milestone.md', description: 'Complete milestone - archive, evolve spec, collapse roadmap' }),
52
- defineWorkflow({ name: 'gsdd-new-milestone', workflow: 'new-milestone.md', description: 'New milestone - gather goals, define requirements, create roadmap phases' }),
53
- defineWorkflow({ name: 'gsdd-plan-milestone-gaps', workflow: 'plan-milestone-gaps.md', description: 'Plan gap closure phases from audit results' }),
54
- defineWorkflow({ name: 'gsdd-quick', workflow: 'quick.md', description: 'Quick task - plan and execute a sub-hour task outside the phase cycle' }),
55
- defineWorkflow({ name: 'gsdd-pause', workflow: 'pause.md', description: 'Pause work - save session context for seamless resumption' }),
56
- defineWorkflow({ name: 'gsdd-resume', workflow: 'resume.md', description: 'Resume work - restore context and route to next action' }),
57
- defineWorkflow({ name: 'gsdd-progress', workflow: 'progress.md', description: 'Check progress - show project status and route to next action', mutatesArtifacts: false }),
58
- ];
59
-
60
- const FRAMEWORK_VERSION = 'v1.4';
61
37
  function createCliContext(cwd = process.cwd()) {
62
38
  return {
63
39
  cwd,
@@ -68,6 +44,9 @@ function createCliContext(cwd = process.cwd()) {
68
44
  packageVersion: PACKAGE_JSON.version,
69
45
  workflows: WORKFLOWS,
70
46
  frameworkVersion: FRAMEWORK_VERSION,
47
+ loadProjectModelConfig,
48
+ getRuntimeModelOverride,
49
+ resolveRuntimeAgentModel,
71
50
  adapters: createAdapterRegistry({
72
51
  cwd,
73
52
  workflows: WORKFLOWS,
@@ -86,8 +65,10 @@ function createCliContext(cwd = process.cwd()) {
86
65
 
87
66
  const INIT_CONTEXT = createCliContext(process.cwd());
88
67
  const cmdInit = createCmdInit(INIT_CONTEXT);
68
+ const cmdInstall = createCmdInstall(INIT_CONTEXT);
89
69
  const cmdHealth = createCmdHealth(INIT_CONTEXT);
90
70
  const cmdCloseoutReport = createCmdCloseoutReport(INIT_CONTEXT);
71
+ const cmdNext = createCmdNext(INIT_CONTEXT);
91
72
 
92
73
  const cmdUpdate = (...updateArgs) => {
93
74
  const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(updateArgs, { cwd: INIT_CONTEXT.cwd });
@@ -98,12 +79,14 @@ const cmdUpdate = (...updateArgs) => {
98
79
  }
99
80
  return createCmdUpdate(createCliContext(workspaceRoot))(...normalizedArgs);
100
81
  };
101
-
102
82
  const COMMANDS = {
103
83
  init: cmdInit,
84
+ install: cmdInstall,
104
85
  update: cmdUpdate,
105
86
  models: cmdModels,
87
+ rigor: cmdRigor,
106
88
  health: cmdHealth,
89
+ next: cmdNext,
107
90
  'file-op': cmdFileOp,
108
91
  'lifecycle-preflight': cmdLifecyclePreflight,
109
92
  'session-fingerprint': cmdSessionFingerprint,
@@ -136,4 +119,4 @@ async function runCli(cliCommand = command, ...cliArgs) {
136
119
  }
137
120
 
138
121
  if (IS_MAIN) await runCli();
139
- export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdControlMap, cmdCloseoutReport, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
122
+ export { cmdHelp, cmdInit, cmdInstall, cmdUpdate, cmdModels, cmdRigor, cmdHealth, cmdNext, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdControlMap, cmdCloseoutReport, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
@@ -21,7 +21,7 @@ function notice(source, severity, entry) {
21
21
  severity,
22
22
  code: entry.code || entry.id || 'unknown',
23
23
  message: entry.message,
24
- fix: entry.fix || entry.fix_hint || null,
24
+ fix: entry.fix_hint || entry.fix || null,
25
25
  path: entry.path || null,
26
26
  };
27
27
  }
@@ -160,18 +160,38 @@ function nextSafeAction({ blockers, warnings, phaseNumber }) {
160
160
  if (blockers.length > 0) {
161
161
  return {
162
162
  command: `gsdd verify ${phaseNumber}`,
163
- reason: 'Repair blockers before treating closeout as replayed.',
163
+ reason: 'Fix blockers first, then re-run closeout replay.',
164
+ };
165
+ }
166
+ const hasWarn = warnings.some((entry) => entry.severity === 'warn');
167
+ if (warnings.length > 0 && hasWarn) {
168
+ const sources = new Set(warnings.filter((entry) => entry.severity === 'warn').map((entry) => entry.source));
169
+ if (sources.has('health')) {
170
+ return {
171
+ command: 'gsdd health --json',
172
+ reason: 'Resolve workspace health warnings before claiming the environment is clean.',
173
+ };
174
+ }
175
+ if (sources.has('ui_proof') || sources.has('phase_verification')) {
176
+ return {
177
+ command: `gsdd verify ${phaseNumber}`,
178
+ reason: 'Resolve phase verification warnings before claiming closeout is replay-clean.',
179
+ };
180
+ }
181
+ return {
182
+ command: 'gsdd control-map --json',
183
+ reason: 'Resolve local state warnings before claiming the environment is clean.',
164
184
  };
165
185
  }
166
186
  if (warnings.length > 0) {
167
187
  return {
168
188
  command: 'gsdd control-map --json',
169
- reason: 'Review warnings before claiming the local environment is clean.',
189
+ reason: 'Review the informational notices before claiming the local environment is clean.',
170
190
  };
171
191
  }
172
192
  return {
173
193
  command: `gsdd verify ${phaseNumber}`,
174
- reason: 'Phase implementation is replay-clean; run formal verification for closure if it has not already been recorded.',
194
+ reason: 'Closeout replay is clean; run formal verification for closure if it has not already been recorded.',
175
195
  };
176
196
  }
177
197
 
@@ -266,11 +286,17 @@ function printHuman(report) {
266
286
  console.log(`Status: ${report.status}`);
267
287
  if (report.blockers.length > 0) {
268
288
  console.log('\nBlockers:');
269
- for (const blocker of report.blockers) console.log(` - [${blocker.source}] ${blocker.code}: ${blocker.message}`);
289
+ for (const blocker of report.blockers) {
290
+ console.log(` - [${blocker.source}] ${blocker.code}: ${blocker.message}`);
291
+ if (blocker.fix) console.log(` Fix: ${blocker.fix}`);
292
+ }
270
293
  }
271
294
  if (report.warnings.length > 0) {
272
295
  console.log('\nWarnings:');
273
- for (const warning of report.warnings) console.log(` - [${warning.source}] ${warning.code}: ${warning.message}`);
296
+ for (const warning of report.warnings) {
297
+ console.log(` - [${warning.source}] ${warning.code}: ${warning.message}`);
298
+ if (warning.fix) console.log(` Fix: ${warning.fix}`);
299
+ }
274
300
  }
275
301
  console.log(`\nNext safe action: ${report.next_safe_action.command}`);
276
302
  console.log(`Reason: ${report.next_safe_action.reason}`);
@@ -982,29 +982,73 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime
982
982
  const writeSetOverlaps = findWriteSetOverlaps(writeEntries);
983
983
  const dirtyWriteSetOverlaps = findDirtyWriteSetOverlaps(writeEntries, dirtyEntries);
984
984
 
985
+ function fixHintForRisk(risk) {
986
+ const code = risk.code;
987
+ switch (code) {
988
+ case 'canonical_git_invalid':
989
+ case 'worktree_git_invalid': {
990
+ const targetPath = risk.worktree_id || canonical.path;
991
+ return `Run \`git config --global --add safe.directory ${targetPath}\`, then re-run \`gsdd control-map --json\`.`;
992
+ }
993
+ case 'canonical_dirty':
994
+ return 'Commit, stash, or checkpoint the canonical changes before planning, cleanup, merge, or broad execution.';
995
+ case 'canonical_dirty_behind_upstream':
996
+ return 'Commit/stash the canonical changes or sync the branch; do not mutate a dirty checkout that is behind upstream.';
997
+ case 'canonical_branch_behind_upstream':
998
+ case 'canonical_branch_diverged_upstream':
999
+ case 'worktree_branch_behind_upstream':
1000
+ case 'worktree_branch_diverged_upstream':
1001
+ return 'Review upstream divergence (fetch/merge/rebase) before treating this branch state as an execution surface.';
1002
+ case 'detached_candidate_worktree':
1003
+ return 'Classify the detached worktree intent (active vs abandoned) before using it for execution or cleanup decisions.';
1004
+ case 'sibling_worktree_dirty':
1005
+ case 'unannotated_candidate_worktree':
1006
+ return 'Review sibling worktree ownership and write set before starting overlapping implementation.';
1007
+ case 'write_set_overlap':
1008
+ return 'Resolve overlapping local annotation write sets before starting another owned-write workflow.';
1009
+ case 'dirty_path_write_set_overlap':
1010
+ return 'Checkpoint or classify dirty paths that overlap annotated write sets before owned-write transitions.';
1011
+ case 'planning_state_drift':
1012
+ return 'Review drift and rebaseline with session-fingerprint only after confirming the planning changes are intentional.';
1013
+ default:
1014
+ return null;
1015
+ }
1016
+ }
1017
+
985
1018
  for (const error of gitErrors) {
986
1019
  risks.push({ code: error.code, severity: 'warn', message: error.message });
987
1020
  }
988
1021
  if (!canonical.git_valid) {
989
- risks.push({ code: 'canonical_git_invalid', severity: 'warn', message: `Canonical worktree git status failed: ${canonical.status_error || 'unknown error'}` });
1022
+ const risk = {
1023
+ code: 'canonical_git_invalid',
1024
+ severity: 'warn',
1025
+ path: normalizeSlashes(canonical.path),
1026
+ message: `Canonical worktree git status failed: ${canonical.status_error || 'unknown error'}`,
1027
+ };
1028
+ risk.fix_hint = fixHintForRisk(risk);
1029
+ risks.push(risk);
990
1030
  }
991
1031
  addBranchStateRisks(risks, canonical, { canonical: true });
992
1032
  if (canonical.dirty.counts.tracked > 0 || canonical.dirty.counts.untracked > 0) {
993
- risks.push({
1033
+ const risk = {
994
1034
  code: 'canonical_dirty',
995
1035
  severity: 'warn',
996
1036
  message: `Canonical worktree has tracked/untracked changes (${canonical.dirty.counts.tracked} tracked, ${canonical.dirty.counts.untracked} untracked).`,
997
- });
1037
+ };
1038
+ risk.fix_hint = fixHintForRisk(risk);
1039
+ risks.push(risk);
998
1040
  }
999
1041
  if (canonical.dirty.counts.tracked > 0 && (canonical.ahead_behind?.behind || 0) > 0) {
1000
- risks.push({
1042
+ const risk = {
1001
1043
  code: 'canonical_dirty_behind_upstream',
1002
1044
  severity: 'block',
1003
1045
  branch: canonical.branch,
1004
1046
  ahead: canonical.ahead_behind?.ahead,
1005
1047
  behind: canonical.ahead_behind?.behind,
1006
1048
  message: `Canonical worktree has tracked changes while behind upstream by ${canonical.ahead_behind.behind} commit(s).`,
1007
- });
1049
+ };
1050
+ risk.fix_hint = fixHintForRisk(risk);
1051
+ risks.push(risk);
1008
1052
  }
1009
1053
  if (canonical.dirty.counts.ignored > 0) {
1010
1054
  risks.push({
@@ -1015,51 +1059,68 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime
1015
1059
  }
1016
1060
  for (const worktree of worktrees.filter((entry) => entry.path !== canonical.path)) {
1017
1061
  if (!worktree.git_valid) {
1018
- risks.push({ code: 'worktree_git_invalid', severity: 'warn', worktree_id: worktree.id, message: `Worktree ${worktree.id} could not be inspected by git.` });
1062
+ const risk = {
1063
+ code: 'worktree_git_invalid',
1064
+ severity: 'warn',
1065
+ worktree_id: worktree.id,
1066
+ message: `Worktree ${worktree.id} could not be inspected by git.`,
1067
+ };
1068
+ risk.fix_hint = fixHintForRisk(risk);
1069
+ risks.push(risk);
1019
1070
  }
1020
1071
  addBranchStateRisks(risks, worktree);
1021
1072
  if (worktree.detached) {
1022
- risks.push({
1073
+ const risk = {
1023
1074
  code: 'detached_candidate_worktree',
1024
1075
  severity: 'warn',
1025
1076
  worktree_id: worktree.id,
1026
1077
  message: `Worktree ${worktree.id} is detached; classify its intent before treating it as an execution surface.`,
1027
- });
1078
+ };
1079
+ risk.fix_hint = fixHintForRisk(risk);
1080
+ risks.push(risk);
1028
1081
  }
1029
1082
  if (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0) {
1030
- risks.push({
1083
+ const risk = {
1031
1084
  code: 'sibling_worktree_dirty',
1032
1085
  severity: 'warn',
1033
1086
  worktree_id: worktree.id,
1034
1087
  message: `Sibling worktree ${worktree.id} has tracked/untracked changes.`,
1035
- });
1088
+ };
1089
+ risk.fix_hint = fixHintForRisk(risk);
1090
+ risks.push(risk);
1036
1091
  }
1037
1092
  if (!worktree.annotation && (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0 || worktree.detached)) {
1038
- risks.push({
1093
+ const risk = {
1039
1094
  code: 'unannotated_candidate_worktree',
1040
1095
  severity: 'info',
1041
1096
  worktree_id: worktree.id,
1042
1097
  message: `Worktree ${worktree.id} has candidate-work signals but no local control-map annotation.`,
1043
- });
1098
+ };
1099
+ risk.fix_hint = fixHintForRisk(risk);
1100
+ risks.push(risk);
1044
1101
  }
1045
1102
  }
1046
1103
  if (writeSetOverlaps.length > 0) {
1047
- risks.push({
1104
+ const risk = {
1048
1105
  code: 'write_set_overlap',
1049
1106
  severity: 'block',
1050
1107
  message: `Active control-map annotations have ${writeSetOverlaps.length} concrete write-set overlap(s).`,
1051
1108
  overlaps: writeSetOverlaps.slice(0, MAX_DIRTY_BUCKET_ENTRIES),
1052
1109
  omitted_count: Math.max(0, writeSetOverlaps.length - MAX_DIRTY_BUCKET_ENTRIES),
1053
- });
1110
+ };
1111
+ risk.fix_hint = fixHintForRisk(risk);
1112
+ risks.push(risk);
1054
1113
  }
1055
1114
  if (dirtyWriteSetOverlaps.length > 0) {
1056
- risks.push({
1115
+ const risk = {
1057
1116
  code: 'dirty_path_write_set_overlap',
1058
1117
  severity: 'block',
1059
1118
  message: `Live dirty paths overlap annotated write sets (${dirtyWriteSetOverlaps.length} overlap(s)).`,
1060
1119
  overlaps: dirtyWriteSetOverlaps.slice(0, MAX_DIRTY_BUCKET_ENTRIES),
1061
1120
  omitted_count: Math.max(0, dirtyWriteSetOverlaps.length - MAX_DIRTY_BUCKET_ENTRIES),
1062
- });
1121
+ };
1122
+ risk.fix_hint = fixHintForRisk(risk);
1123
+ risks.push(risk);
1063
1124
  }
1064
1125
  for (const warning of annotations.warnings || []) risks.push(warning);
1065
1126
  for (const error of annotations.errors || []) {
@@ -1074,11 +1135,22 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime
1074
1135
  });
1075
1136
  }
1076
1137
  if (workflowState.planning_drift.drifted) {
1077
- risks.push({
1138
+ const risk = {
1078
1139
  code: 'planning_state_drift',
1079
1140
  severity: 'warn',
1080
1141
  message: `Planning state drifted since the last fingerprint: ${workflowState.planning_drift.details.join('; ')}`,
1081
- });
1142
+ };
1143
+ risk.fix_hint = fixHintForRisk(risk);
1144
+ risks.push(risk);
1145
+ }
1146
+
1147
+ // Ensure the common closure risks expose actionable fix guidance even when
1148
+ // the originating helper (for example branch-state risks) didn't attach it.
1149
+ for (const risk of risks) {
1150
+ if (!risk.fix_hint) {
1151
+ const hint = fixHintForRisk(risk);
1152
+ if (hint) risk.fix_hint = hint;
1153
+ }
1082
1154
  }
1083
1155
  return risks;
1084
1156
  }
@@ -1175,7 +1247,10 @@ function printHuman(map) {
1175
1247
  }
1176
1248
  if (map.risks.length > 0) {
1177
1249
  console.log('\nRisks:');
1178
- for (const risk of map.risks) console.log(` - [${risk.severity || 'info'}] ${risk.code}: ${risk.message}`);
1250
+ for (const risk of map.risks) {
1251
+ console.log(` - [${risk.severity || 'info'}] ${risk.code}: ${risk.message}`);
1252
+ if (risk.fix_hint) console.log(` Fix: ${risk.fix_hint}`);
1253
+ }
1179
1254
  }
1180
1255
  console.log('\nInterventions:');
1181
1256
  for (const intervention of map.interventions) console.log(` - ${intervention}`);