@topogram/cli 0.3.72 → 0.3.73

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.
Files changed (81) hide show
  1. package/README.md +24 -195
  2. package/package.json +1 -1
  3. package/src/adoption/plan/index.js +2 -1
  4. package/src/agent-brief.js +46 -2
  5. package/src/archive/archive.js +1 -1
  6. package/src/archive/jsonl.js +18 -8
  7. package/src/archive/resolver-bridge.js +34 -1
  8. package/src/archive/schema.js +1 -1
  9. package/src/archive/unarchive.js +26 -0
  10. package/src/cli/command-parsers/sdlc.js +66 -0
  11. package/src/cli/commands/import/help.js +1 -0
  12. package/src/cli/commands/import/plan.js +9 -0
  13. package/src/cli/commands/import/workspace.js +3 -0
  14. package/src/cli/commands/query/definitions.js +11 -10
  15. package/src/cli/commands/query/workspace.js +23 -2
  16. package/src/cli/commands/sdlc.js +213 -5
  17. package/src/cli/dispatcher.js +8 -0
  18. package/src/cli/help.js +14 -2
  19. package/src/cli/options.js +1 -0
  20. package/src/generator/context/shared/domain-sdlc.js +27 -0
  21. package/src/generator/context/shared/relationships.js +2 -1
  22. package/src/generator/context/shared/types.d.ts +1 -0
  23. package/src/generator/context/shared.d.ts +2 -0
  24. package/src/generator/context/shared.js +2 -0
  25. package/src/generator/context/slice/core.js +3 -0
  26. package/src/generator/context/slice/sdlc.js +57 -2
  27. package/src/generator/context/task-mode.js +7 -0
  28. package/src/generator/sdlc/board.js +2 -0
  29. package/src/generator/sdlc/traceability-matrix.js +5 -1
  30. package/src/import/core/context.js +1 -1
  31. package/src/import/core/contracts.js +3 -3
  32. package/src/import/core/registry.js +3 -0
  33. package/src/import/core/runner/candidates.js +7 -0
  34. package/src/import/core/runner/reports.js +9 -1
  35. package/src/import/core/runner/tracks.js +3 -0
  36. package/src/import/extractors/cli/generic.js +340 -0
  37. package/src/new-project/project-files.js +10 -3
  38. package/src/resolver/enrich/task.js +3 -1
  39. package/src/resolver/index.js +6 -0
  40. package/src/resolver/normalize.js +31 -0
  41. package/src/resolver/projections-cli.js +158 -0
  42. package/src/sdlc/adopt.js +4 -1
  43. package/src/sdlc/check.js +24 -2
  44. package/src/sdlc/complete.js +47 -0
  45. package/src/sdlc/dod/index.js +2 -0
  46. package/src/sdlc/dod/plan.js +15 -0
  47. package/src/sdlc/dod/task.js +7 -3
  48. package/src/sdlc/explain.js +53 -1
  49. package/src/sdlc/gate.js +352 -0
  50. package/src/sdlc/history.d.ts +7 -0
  51. package/src/sdlc/history.js +50 -5
  52. package/src/sdlc/link.js +172 -0
  53. package/src/sdlc/paths.d.ts +4 -0
  54. package/src/sdlc/paths.js +8 -0
  55. package/src/sdlc/plan-steps.js +71 -0
  56. package/src/sdlc/plan.js +245 -0
  57. package/src/sdlc/policy.js +249 -0
  58. package/src/sdlc/prep.js +186 -0
  59. package/src/sdlc/scaffold.js +4 -2
  60. package/src/sdlc/status-filter.js +2 -0
  61. package/src/sdlc/transitions/index.js +3 -0
  62. package/src/sdlc/transitions/plan.js +32 -0
  63. package/src/validator/common.js +25 -4
  64. package/src/validator/index.js +10 -0
  65. package/src/validator/kinds.d.ts +7 -0
  66. package/src/validator/kinds.js +32 -0
  67. package/src/validator/per-kind/plan.js +128 -0
  68. package/src/validator/per-kind/task.js +19 -0
  69. package/src/validator/projections/cli.js +267 -0
  70. package/src/validator.d.ts +1 -0
  71. package/src/workflows/import-app/shared.js +1 -1
  72. package/src/workflows/reconcile/adoption-plan/build.js +3 -1
  73. package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
  74. package/src/workflows/reconcile/bundle-core/index.js +3 -0
  75. package/src/workflows/reconcile/candidate-model.js +15 -0
  76. package/src/workflows/reconcile/gap-report.js +4 -2
  77. package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
  78. package/src/workflows/reconcile/renderers.js +82 -0
  79. package/src/workflows/reconcile/summary.js +4 -0
  80. package/src/workflows/reconcile/workflow.js +2 -1
  81. package/src/workspace-paths.js +26 -2
@@ -0,0 +1,71 @@
1
+ // @ts-check
2
+
3
+ import { blockEntries } from "../validator.js";
4
+
5
+ /**
6
+ * @typedef {{
7
+ * id: string|null,
8
+ * status: string|null,
9
+ * description: string|null,
10
+ * notes: string|null,
11
+ * outcome: string|null,
12
+ * raw: string[],
13
+ * loc: any
14
+ * }} PlanStep
15
+ */
16
+
17
+ /**
18
+ * @param {any} token
19
+ * @returns {string|null}
20
+ */
21
+ function tokenValue(token) {
22
+ return token && (token.type === "symbol" || token.type === "string") ? token.value : null;
23
+ }
24
+
25
+ /**
26
+ * @param {any[]} items
27
+ * @param {string} key
28
+ * @returns {string|null}
29
+ */
30
+ function keyedValue(items, key) {
31
+ for (let index = 2; index < items.length - 1; index += 2) {
32
+ if (tokenValue(items[index]) === key) {
33
+ return tokenValue(items[index + 1]);
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * @param {any} entry
41
+ * @returns {PlanStep}
42
+ */
43
+ export function parsePlanStepEntry(entry) {
44
+ const items = entry?.items || [];
45
+ return {
46
+ id: tokenValue(items[1]),
47
+ status: keyedValue(items, "status"),
48
+ description: keyedValue(items, "description"),
49
+ notes: keyedValue(items, "notes"),
50
+ outcome: keyedValue(items, "outcome"),
51
+ raw: items.map(/** @param {any} item */ (item) => tokenValue(item)).filter(/** @param {string|null} value */ (value) => value !== null),
52
+ loc: entry?.loc || null
53
+ };
54
+ }
55
+
56
+ /**
57
+ * @param {any} value
58
+ * @returns {PlanStep[]}
59
+ */
60
+ export function parsePlanSteps(value) {
61
+ return blockEntries(value).map((entry) => parsePlanStepEntry(entry));
62
+ }
63
+
64
+ /**
65
+ * @param {string} planId
66
+ * @param {string} stepId
67
+ * @returns {string}
68
+ */
69
+ export function planStepHistoryId(planId, stepId) {
70
+ return `${planId}#${stepId}`;
71
+ }
@@ -0,0 +1,245 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import { parsePath } from "../parser.js";
7
+ import { resolveWorkspace } from "../resolver.js";
8
+ import { PLAN_STEP_STATUSES } from "../validator/kinds.js";
9
+ import { appendTransition, lastTransition, readHistory } from "./history.js";
10
+ import { sdlcRootForSdlc } from "./paths.js";
11
+ import { parsePlanStepEntry, planStepHistoryId } from "./plan-steps.js";
12
+
13
+ /** @type {Record<string, string[]>} */
14
+ const STEP_TRANSITIONS = {
15
+ pending: ["in-progress", "blocked", "done", "skipped"],
16
+ "in-progress": ["done", "blocked", "pending", "skipped"],
17
+ blocked: ["pending", "in-progress", "skipped"],
18
+ done: [],
19
+ skipped: []
20
+ };
21
+
22
+ /**
23
+ * @param {string} slug
24
+ * @returns {string}
25
+ */
26
+ function humanize(slug) {
27
+ return slug
28
+ .replace(/[_-]+/g, " ")
29
+ .replace(/\b\w/g, (m) => m.toUpperCase());
30
+ }
31
+
32
+ /**
33
+ * @param {any} ast
34
+ * @param {string} id
35
+ * @returns {{file: any, statement: any}|null}
36
+ */
37
+ function findAstStatement(ast, id) {
38
+ for (const file of ast.files) {
39
+ for (const statement of file.statements) {
40
+ if (statement.id === id) {
41
+ return { file, statement };
42
+ }
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * @param {any} statement
50
+ * @param {string} stepId
51
+ * @returns {{entry: any, parsed: import("./plan-steps.js").PlanStep, statusToken: any}|null}
52
+ */
53
+ function findStepEntry(statement, stepId) {
54
+ const stepsField = statement.fields.find(/** @param {any} field */ (field) => field.key === "steps");
55
+ if (!stepsField || stepsField.value.type !== "block") return null;
56
+ for (const entry of stepsField.value.entries) {
57
+ const parsed = parsePlanStepEntry(entry);
58
+ if (parsed.id !== stepId) continue;
59
+ for (let index = 2; index < entry.items.length - 1; index += 2) {
60
+ if (entry.items[index]?.type === "symbol" && entry.items[index].value === "status") {
61
+ return { entry, parsed, statusToken: entry.items[index + 1] };
62
+ }
63
+ }
64
+ return { entry, parsed, statusToken: null };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * @param {string} source
71
+ * @param {any} token
72
+ * @param {string} status
73
+ * @returns {string}
74
+ */
75
+ function rewriteToken(source, token, status) {
76
+ return source.slice(0, token.loc.start.offset) + status + source.slice(token.loc.end.offset);
77
+ }
78
+
79
+ /**
80
+ * @param {string} workspaceRoot
81
+ * @param {string} taskId
82
+ * @param {string} slug
83
+ * @param {{write?: boolean}} [options]
84
+ * @returns {any}
85
+ */
86
+ export function createPlan(workspaceRoot, taskId, slug, options = {}) {
87
+ if (!taskId || !/^task_[a-z][a-z0-9_]*$/.test(taskId)) {
88
+ return { ok: false, error: `Invalid task id '${taskId}'` };
89
+ }
90
+ if (!slug || !/^[a-z][a-z0-9_]*$/.test(slug)) {
91
+ return { ok: false, error: `Invalid slug '${slug}' — must match /^[a-z][a-z0-9_]*$/` };
92
+ }
93
+
94
+ const ast = parsePath(workspaceRoot);
95
+ const resolved = resolveWorkspace(ast);
96
+ if (!resolved.ok) {
97
+ return { ok: false, error: "workspace failed validation; cannot create plan", validation: resolved.validation };
98
+ }
99
+ const task = resolved.graph.statements.find(/** @param {any} statement */ (statement) => statement.id === taskId && statement.kind === "task");
100
+ if (!task) {
101
+ return { ok: false, error: `Task '${taskId}' not found` };
102
+ }
103
+
104
+ const planId = `plan_${slug}`;
105
+ if (resolved.graph.statements.some(/** @param {any} statement */ (statement) => statement.id === planId)) {
106
+ return { ok: false, error: `Statement '${planId}' already exists` };
107
+ }
108
+
109
+ const targetDir = path.join(sdlcRootForSdlc(workspaceRoot), "plans");
110
+ const targetFile = path.join(targetDir, `${slug}.tg`);
111
+ const content = `plan ${planId} {
112
+ name "${humanize(slug)}"
113
+ description "Implementation plan for ${taskId}."
114
+ task ${taskId}
115
+ priority medium
116
+ notes "Record approach notes here."
117
+ outcome "Record what worked, what did not, and what to repeat later."
118
+ steps {
119
+ step inspect_current_state status pending description "Inspect current behavior and constraints."
120
+ step implement_changes status pending description "Implement the planned changes."
121
+ step verify_results status pending description "Run focused checks and record verification."
122
+ }
123
+ status draft
124
+ }
125
+ `;
126
+
127
+ if (!options.write) {
128
+ return { ok: true, dryRun: true, id: planId, task: taskId, file: targetFile, content };
129
+ }
130
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
131
+ if (fs.existsSync(targetFile)) {
132
+ return { ok: false, error: `Refusing to overwrite '${targetFile}'` };
133
+ }
134
+ fs.writeFileSync(targetFile, content, "utf8");
135
+ return { ok: true, dryRun: false, id: planId, task: taskId, file: targetFile };
136
+ }
137
+
138
+ /**
139
+ * @param {string} workspaceRoot
140
+ * @param {string} planId
141
+ * @returns {any}
142
+ */
143
+ export function explainPlan(workspaceRoot, planId) {
144
+ const ast = parsePath(workspaceRoot);
145
+ const resolved = resolveWorkspace(ast);
146
+ if (!resolved.ok) {
147
+ return { ok: false, error: "workspace failed validation; cannot explain plan", validation: resolved.validation };
148
+ }
149
+ const plan = resolved.graph.statements.find(/** @param {any} statement */ (statement) => statement.id === planId && statement.kind === "plan");
150
+ if (!plan) {
151
+ return { ok: false, error: `Plan '${planId}' not found` };
152
+ }
153
+ const history = readHistory(workspaceRoot);
154
+ const steps = (plan.steps || []).map(/** @param {any} step */ (step) => ({
155
+ ...step,
156
+ last_transition: lastTransition(history, planStepHistoryId(plan.id, step.id))
157
+ }));
158
+ const nextStep = steps.find(/** @param {any} step */ (step) => step.status !== "done" && step.status !== "skipped") || null;
159
+ return {
160
+ ok: true,
161
+ type: "sdlc_plan_explain",
162
+ id: plan.id,
163
+ status: plan.status,
164
+ task: plan.task?.id || null,
165
+ summary: {
166
+ name: plan.name,
167
+ description: plan.description,
168
+ notes: plan.notes || null,
169
+ outcome: plan.outcome || null
170
+ },
171
+ steps,
172
+ next_step: nextStep,
173
+ next_action: nextStep
174
+ ? { kind: "work", step: nextStep.id, reason: `next incomplete step is '${nextStep.id}'` }
175
+ : { kind: "none", reason: "all steps are done or skipped" }
176
+ };
177
+ }
178
+
179
+ /**
180
+ * @param {string} workspaceRoot
181
+ * @param {string} planId
182
+ * @param {string} stepId
183
+ * @param {string} targetStatus
184
+ * @param {{write?: boolean, dryRun?: boolean, actor?: string|null, note?: string|null}} [options]
185
+ * @returns {any}
186
+ */
187
+ export function transitionPlanStep(workspaceRoot, planId, stepId, targetStatus, options = {}) {
188
+ if (!PLAN_STEP_STATUSES.has(targetStatus)) {
189
+ return { ok: false, error: `Invalid plan step status '${targetStatus}'` };
190
+ }
191
+
192
+ const ast = parsePath(workspaceRoot);
193
+ const resolved = resolveWorkspace(ast);
194
+ if (!resolved.ok) {
195
+ return { ok: false, error: "workspace failed validation; cannot transition plan step", validation: resolved.validation };
196
+ }
197
+
198
+ const located = findAstStatement(ast, planId);
199
+ if (!located || located.statement.kind !== "plan") {
200
+ return { ok: false, error: `Plan '${planId}' not found` };
201
+ }
202
+ const step = findStepEntry(located.statement, stepId);
203
+ if (!step) {
204
+ return { ok: false, error: `Step '${stepId}' not found on plan '${planId}'` };
205
+ }
206
+ if (!step.statusToken) {
207
+ return { ok: false, error: `Step '${stepId}' on plan '${planId}' has no status field to rewrite` };
208
+ }
209
+
210
+ const fromStatus = step.parsed.status;
211
+ const allowed = STEP_TRANSITIONS[fromStatus || ""];
212
+ if (!allowed) {
213
+ return { ok: false, error: `Unknown plan step status '${fromStatus}'` };
214
+ }
215
+ if (!allowed.includes(targetStatus)) {
216
+ return {
217
+ ok: false,
218
+ error: `Plan step cannot transition from '${fromStatus}' to '${targetStatus}' — allowed: ${allowed.join(", ") || "(terminal)"}`
219
+ };
220
+ }
221
+
222
+ const sourcePath = located.statement.loc.file;
223
+ const original = fs.readFileSync(sourcePath, "utf8");
224
+ const rewritten = rewriteToken(original, step.statusToken, targetStatus);
225
+ const shouldWrite = options.write === true && options.dryRun !== true;
226
+ if (shouldWrite) {
227
+ fs.writeFileSync(sourcePath, rewritten, "utf8");
228
+ appendTransition(workspaceRoot, planStepHistoryId(planId, stepId), {
229
+ from: fromStatus,
230
+ to: targetStatus,
231
+ by: options.actor || null,
232
+ note: options.note || null
233
+ });
234
+ }
235
+
236
+ return {
237
+ ok: true,
238
+ id: planId,
239
+ step: stepId,
240
+ from: fromStatus,
241
+ to: targetStatus,
242
+ file: sourcePath,
243
+ dryRun: !shouldWrite
244
+ };
245
+ }
@@ -0,0 +1,249 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import { resolveWorkspaceContext } from "../workspace-paths.js";
7
+
8
+ export const SDLC_POLICY_FILE = "topogram.sdlc-policy.json";
9
+ export const SDLC_POLICY_VERSION = "1";
10
+
11
+ export const SDLC_POLICY_STATUSES = new Set(["adopted", "not_adopted"]);
12
+ export const SDLC_POLICY_MODES = new Set(["advisory", "enforced"]);
13
+ export const SDLC_POLICY_ITEM_KINDS = new Set(["task", "bug", "requirement", "pitch"]);
14
+
15
+ export const DEFAULT_PROTECTED_PATHS = [
16
+ "engine/**",
17
+ "docs/**",
18
+ "scripts/**",
19
+ ".github/**",
20
+ "topo/**",
21
+ "AGENTS.md",
22
+ "CONTRIBUTING.md",
23
+ "README.md",
24
+ "package.json"
25
+ ];
26
+
27
+ export const DEFAULT_REQUIRED_ITEM_KINDS = ["task", "bug", "requirement", "pitch"];
28
+
29
+ /**
30
+ * @typedef {Object} SdlcPolicy
31
+ * @property {string} version
32
+ * @property {"adopted"|"not_adopted"} status
33
+ * @property {"advisory"|"enforced"} mode
34
+ * @property {string[]} protectedPaths
35
+ * @property {string[]} requiredItemKinds
36
+ * @property {boolean} allowExemptions
37
+ * @property {boolean} releaseNotes
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} SdlcPolicyInfo
42
+ * @property {boolean} exists
43
+ * @property {string} path
44
+ * @property {SdlcPolicy|null} policy
45
+ * @property {"adopted"|"not_adopted"} status
46
+ * @property {"advisory"|"enforced"|null} mode
47
+ * @property {Array<{ severity: "error"|"warning", message: string }>} diagnostics
48
+ */
49
+
50
+ /**
51
+ * @returns {SdlcPolicy}
52
+ */
53
+ export function defaultSdlcPolicy() {
54
+ return {
55
+ version: SDLC_POLICY_VERSION,
56
+ status: "adopted",
57
+ mode: "enforced",
58
+ protectedPaths: [...DEFAULT_PROTECTED_PATHS],
59
+ requiredItemKinds: [...DEFAULT_REQUIRED_ITEM_KINDS],
60
+ allowExemptions: true,
61
+ releaseNotes: true
62
+ };
63
+ }
64
+
65
+ /**
66
+ * @param {string} inputPath
67
+ * @returns {string}
68
+ */
69
+ export function policyProjectRoot(inputPath = ".") {
70
+ return resolveWorkspaceContext(inputPath || ".").projectRoot;
71
+ }
72
+
73
+ /**
74
+ * @param {string} value
75
+ * @returns {boolean}
76
+ */
77
+ function isSafeRelativePattern(value) {
78
+ const normalized = value.replace(/\\/g, "/").trim();
79
+ return Boolean(normalized) &&
80
+ !path.isAbsolute(normalized) &&
81
+ normalized !== ".." &&
82
+ !normalized.startsWith("../") &&
83
+ !normalized.includes("/../") &&
84
+ !normalized.endsWith("/..");
85
+ }
86
+
87
+ /**
88
+ * @param {unknown} value
89
+ * @returns {string[]}
90
+ */
91
+ function stringArray(value) {
92
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
93
+ }
94
+
95
+ /**
96
+ * @param {unknown} value
97
+ * @returns {{ ok: boolean, policy: SdlcPolicy|null, diagnostics: SdlcPolicyInfo["diagnostics"] }}
98
+ */
99
+ export function validateSdlcPolicy(value) {
100
+ /** @type {SdlcPolicyInfo["diagnostics"]} */
101
+ const diagnostics = [];
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
103
+ return {
104
+ ok: false,
105
+ policy: null,
106
+ diagnostics: [{ severity: "error", message: `${SDLC_POLICY_FILE} must contain a JSON object.` }]
107
+ };
108
+ }
109
+
110
+ const record = /** @type {Record<string, unknown>} */ (value);
111
+ if (record.version !== SDLC_POLICY_VERSION) {
112
+ diagnostics.push({ severity: "error", message: `SDLC policy version must be "${SDLC_POLICY_VERSION}".` });
113
+ }
114
+ if (typeof record.status !== "string" || !SDLC_POLICY_STATUSES.has(record.status)) {
115
+ diagnostics.push({ severity: "error", message: "SDLC policy status must be adopted or not_adopted." });
116
+ }
117
+ if (typeof record.mode !== "string" || !SDLC_POLICY_MODES.has(record.mode)) {
118
+ diagnostics.push({ severity: "error", message: "SDLC policy mode must be advisory or enforced." });
119
+ }
120
+ if (record.status === "not_adopted" && record.mode === "enforced") {
121
+ diagnostics.push({ severity: "error", message: "SDLC policy cannot be not_adopted and enforced." });
122
+ }
123
+ if (!Array.isArray(record.protectedPaths) || record.protectedPaths.length === 0) {
124
+ diagnostics.push({ severity: "error", message: "SDLC policy protectedPaths must be a non-empty string array." });
125
+ } else {
126
+ for (const item of record.protectedPaths) {
127
+ if (typeof item !== "string" || !isSafeRelativePattern(item)) {
128
+ diagnostics.push({ severity: "error", message: `Invalid protected path '${String(item)}'. Paths must be relative and must not escape the project root.` });
129
+ }
130
+ }
131
+ }
132
+ if (!Array.isArray(record.requiredItemKinds) || record.requiredItemKinds.length === 0) {
133
+ diagnostics.push({ severity: "error", message: "SDLC policy requiredItemKinds must be a non-empty string array." });
134
+ } else {
135
+ for (const item of record.requiredItemKinds) {
136
+ if (typeof item !== "string" || !SDLC_POLICY_ITEM_KINDS.has(item)) {
137
+ diagnostics.push({ severity: "error", message: `Invalid required SDLC item kind '${String(item)}'.` });
138
+ }
139
+ }
140
+ }
141
+ if (typeof record.allowExemptions !== "boolean") {
142
+ diagnostics.push({ severity: "error", message: "SDLC policy allowExemptions must be a boolean." });
143
+ }
144
+ if (typeof record.releaseNotes !== "boolean") {
145
+ diagnostics.push({ severity: "error", message: "SDLC policy releaseNotes must be a boolean." });
146
+ }
147
+
148
+ const ok = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
149
+ if (!ok) {
150
+ return { ok, policy: null, diagnostics };
151
+ }
152
+
153
+ return {
154
+ ok,
155
+ policy: {
156
+ version: String(record.version),
157
+ status: /** @type {"adopted"|"not_adopted"} */ (record.status),
158
+ mode: /** @type {"advisory"|"enforced"} */ (record.mode),
159
+ protectedPaths: stringArray(record.protectedPaths),
160
+ requiredItemKinds: stringArray(record.requiredItemKinds),
161
+ allowExemptions: Boolean(record.allowExemptions),
162
+ releaseNotes: Boolean(record.releaseNotes)
163
+ },
164
+ diagnostics
165
+ };
166
+ }
167
+
168
+ /**
169
+ * @param {string} projectRoot
170
+ * @returns {SdlcPolicyInfo}
171
+ */
172
+ export function loadSdlcPolicy(projectRoot) {
173
+ const policyPath = path.join(projectRoot, SDLC_POLICY_FILE);
174
+ if (!fs.existsSync(policyPath)) {
175
+ return {
176
+ exists: false,
177
+ path: policyPath,
178
+ policy: null,
179
+ status: "not_adopted",
180
+ mode: null,
181
+ diagnostics: []
182
+ };
183
+ }
184
+ try {
185
+ const parsed = JSON.parse(fs.readFileSync(policyPath, "utf8"));
186
+ const validation = validateSdlcPolicy(parsed);
187
+ return {
188
+ exists: true,
189
+ path: policyPath,
190
+ policy: validation.policy,
191
+ status: validation.policy?.status || "not_adopted",
192
+ mode: validation.policy?.mode || null,
193
+ diagnostics: validation.diagnostics
194
+ };
195
+ } catch (error) {
196
+ return {
197
+ exists: true,
198
+ path: policyPath,
199
+ policy: null,
200
+ status: "not_adopted",
201
+ mode: null,
202
+ diagnostics: [{ severity: "error", message: `Could not read ${SDLC_POLICY_FILE}: ${error instanceof Error ? error.message : String(error)}` }]
203
+ };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @param {string} projectRoot
209
+ * @returns {{ ok: boolean, file: string, policy: SdlcPolicy }}
210
+ */
211
+ export function writeDefaultSdlcPolicy(projectRoot) {
212
+ const file = path.join(projectRoot, SDLC_POLICY_FILE);
213
+ if (fs.existsSync(file)) {
214
+ throw new Error(`${SDLC_POLICY_FILE} already exists.`);
215
+ }
216
+ const policy = defaultSdlcPolicy();
217
+ fs.writeFileSync(file, `${JSON.stringify(policy, null, 2)}\n`, "utf8");
218
+ return { ok: true, file, policy };
219
+ }
220
+
221
+ /**
222
+ * @param {string} projectRoot
223
+ * @returns {Record<string, any>}
224
+ */
225
+ export function explainSdlcPolicy(projectRoot) {
226
+ const info = loadSdlcPolicy(projectRoot);
227
+ return {
228
+ type: "sdlc_policy_explain",
229
+ version: "1",
230
+ ok: info.diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
231
+ policy: {
232
+ exists: info.exists,
233
+ path: info.path,
234
+ status: info.status,
235
+ mode: info.mode,
236
+ protectedPaths: info.policy?.protectedPaths || [],
237
+ requiredItemKinds: info.policy?.requiredItemKinds || [],
238
+ allowExemptions: info.policy?.allowExemptions ?? false,
239
+ releaseNotes: info.policy?.releaseNotes ?? false
240
+ },
241
+ diagnostics: info.diagnostics,
242
+ enforcement: info.status === "adopted"
243
+ ? (info.mode === "enforced" ? "Protected changes require SDLC linkage or an allowed exemption." : "Gaps are reported without failing.")
244
+ : "Project has not adopted enforced SDLC.",
245
+ nextCommands: info.exists
246
+ ? ["topogram sdlc policy check --json", "topogram sdlc gate . --require-adopted --json"]
247
+ : ["topogram sdlc policy init .", "topogram sdlc policy check --json"]
248
+ };
249
+ }