@topogram/cli 0.3.72 → 0.3.74

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 (84) 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/release-rollout.js +191 -10
  17. package/src/cli/commands/release-shared.js +51 -2
  18. package/src/cli/commands/release.js +16 -3
  19. package/src/cli/commands/sdlc.js +213 -5
  20. package/src/cli/dispatcher.js +8 -0
  21. package/src/cli/help.js +15 -3
  22. package/src/cli/options.js +1 -0
  23. package/src/generator/context/shared/domain-sdlc.js +27 -0
  24. package/src/generator/context/shared/relationships.js +2 -1
  25. package/src/generator/context/shared/types.d.ts +1 -0
  26. package/src/generator/context/shared.d.ts +2 -0
  27. package/src/generator/context/shared.js +2 -0
  28. package/src/generator/context/slice/core.js +3 -0
  29. package/src/generator/context/slice/sdlc.js +57 -2
  30. package/src/generator/context/task-mode.js +7 -0
  31. package/src/generator/sdlc/board.js +2 -0
  32. package/src/generator/sdlc/traceability-matrix.js +5 -1
  33. package/src/import/core/context.js +1 -1
  34. package/src/import/core/contracts.js +3 -3
  35. package/src/import/core/registry.js +3 -0
  36. package/src/import/core/runner/candidates.js +7 -0
  37. package/src/import/core/runner/reports.js +9 -1
  38. package/src/import/core/runner/tracks.js +3 -0
  39. package/src/import/extractors/cli/generic.js +340 -0
  40. package/src/new-project/project-files.js +10 -3
  41. package/src/resolver/enrich/task.js +3 -1
  42. package/src/resolver/index.js +6 -0
  43. package/src/resolver/normalize.js +31 -0
  44. package/src/resolver/projections-cli.js +158 -0
  45. package/src/sdlc/adopt.js +4 -1
  46. package/src/sdlc/check.js +24 -2
  47. package/src/sdlc/complete.js +47 -0
  48. package/src/sdlc/dod/index.js +2 -0
  49. package/src/sdlc/dod/plan.js +15 -0
  50. package/src/sdlc/dod/task.js +7 -3
  51. package/src/sdlc/explain.js +53 -1
  52. package/src/sdlc/gate.js +352 -0
  53. package/src/sdlc/history.d.ts +7 -0
  54. package/src/sdlc/history.js +50 -5
  55. package/src/sdlc/link.js +172 -0
  56. package/src/sdlc/paths.d.ts +4 -0
  57. package/src/sdlc/paths.js +8 -0
  58. package/src/sdlc/plan-steps.js +71 -0
  59. package/src/sdlc/plan.js +245 -0
  60. package/src/sdlc/policy.js +249 -0
  61. package/src/sdlc/prep.js +186 -0
  62. package/src/sdlc/scaffold.js +4 -2
  63. package/src/sdlc/status-filter.js +2 -0
  64. package/src/sdlc/transitions/index.js +3 -0
  65. package/src/sdlc/transitions/plan.js +32 -0
  66. package/src/validator/common.js +25 -4
  67. package/src/validator/index.js +10 -0
  68. package/src/validator/kinds.d.ts +7 -0
  69. package/src/validator/kinds.js +32 -0
  70. package/src/validator/per-kind/plan.js +128 -0
  71. package/src/validator/per-kind/task.js +19 -0
  72. package/src/validator/projections/cli.js +267 -0
  73. package/src/validator.d.ts +1 -0
  74. package/src/workflows/import-app/shared.js +1 -1
  75. package/src/workflows/reconcile/adoption-plan/build.js +3 -1
  76. package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
  77. package/src/workflows/reconcile/bundle-core/index.js +3 -0
  78. package/src/workflows/reconcile/candidate-model.js +15 -0
  79. package/src/workflows/reconcile/gap-report.js +4 -2
  80. package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
  81. package/src/workflows/reconcile/renderers.js +82 -0
  82. package/src/workflows/reconcile/summary.js +4 -0
  83. package/src/workflows/reconcile/workflow.js +2 -1
  84. package/src/workspace-paths.js +26 -2
@@ -0,0 +1,186 @@
1
+ // @ts-check
2
+
3
+ import childProcess from "node:child_process";
4
+ import path from "node:path";
5
+
6
+ import { parsePath } from "../parser.js";
7
+ import { resolveWorkspace } from "../resolver.js";
8
+ import { resolveTopoRoot, resolveWorkspaceContext } from "../workspace-paths.js";
9
+
10
+ /**
11
+ * @typedef {Object} SdlcCommitPrepOptions
12
+ * @property {string|null} [base]
13
+ * @property {string|null} [head]
14
+ * @property {string[]} [changedFiles]
15
+ */
16
+
17
+ /**
18
+ * @param {string} value
19
+ * @returns {string}
20
+ */
21
+ function normalizePath(value) {
22
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
23
+ }
24
+
25
+ /**
26
+ * @param {string} projectRoot
27
+ * @param {string[]} args
28
+ * @returns {string[]}
29
+ */
30
+ function gitFileList(projectRoot, args) {
31
+ const result = childProcess.spawnSync("git", args, {
32
+ cwd: projectRoot,
33
+ encoding: "utf8"
34
+ });
35
+ if (result.status !== 0) {
36
+ return [];
37
+ }
38
+ return result.stdout.split(/\r?\n/).map(/** @param {string} line */ (line) => line.trim()).filter(Boolean);
39
+ }
40
+
41
+ /**
42
+ * @param {string} projectRoot
43
+ * @param {string|null|undefined} base
44
+ * @param {string|null|undefined} head
45
+ * @returns {string[]}
46
+ */
47
+ function changedFiles(projectRoot, base, head) {
48
+ const localChanges = [
49
+ ...gitFileList(projectRoot, ["diff", "--name-only", "--cached"]),
50
+ ...gitFileList(projectRoot, ["diff", "--name-only"]),
51
+ ...gitFileList(projectRoot, ["ls-files", "--others", "--exclude-standard"])
52
+ ];
53
+ if (base && head) {
54
+ return [...new Set([
55
+ ...gitFileList(projectRoot, ["diff", "--name-only", `${base}...${head}`]),
56
+ ...localChanges
57
+ ])];
58
+ }
59
+ return [...new Set(localChanges)];
60
+ }
61
+
62
+ /**
63
+ * @param {Record<string, any>} ast
64
+ * @param {string} projectRoot
65
+ * @returns {Map<string, { file: string, ids: string[] }>}
66
+ */
67
+ function taskFilesFromAst(ast, projectRoot) {
68
+ const map = new Map();
69
+ for (const file of ast.files || []) {
70
+ const ids = (file.statements || [])
71
+ .filter(/** @param {Record<string, any>} statement */ (statement) => statement.kind === "task")
72
+ .map(/** @param {Record<string, any>} statement */ (statement) => statement.id);
73
+ if (ids.length === 0) continue;
74
+ const rel = normalizePath(path.relative(projectRoot, file.file));
75
+ map.set(rel, { file: rel, ids });
76
+ }
77
+ return map;
78
+ }
79
+
80
+ /**
81
+ * @param {Record<string, any>} task
82
+ * @returns {boolean}
83
+ */
84
+ function isOpenTask(task) {
85
+ return task.status !== "done";
86
+ }
87
+
88
+ /**
89
+ * @param {Record<string, any>} task
90
+ * @returns {boolean}
91
+ */
92
+ function needsExplicitDisposition(task) {
93
+ if (!isOpenTask(task)) return false;
94
+ if (task.disposition) return false;
95
+ return !["claimed", "in-progress"].includes(String(task.status));
96
+ }
97
+
98
+ /**
99
+ * @param {string} inputPath
100
+ * @param {SdlcCommitPrepOptions} [options]
101
+ * @returns {Record<string, any>}
102
+ */
103
+ export function runSdlcCommitPrep(inputPath = ".", options = {}) {
104
+ const context = resolveWorkspaceContext(inputPath || ".");
105
+ const projectRoot = context.projectRoot;
106
+ const topogramRoot = resolveTopoRoot(inputPath || ".");
107
+ const files = (options.changedFiles || changedFiles(projectRoot, options.base, options.head)).map(normalizePath);
108
+ const changedFileSet = new Set(files);
109
+ const ast = parsePath(topogramRoot);
110
+ const resolved = resolveWorkspace(ast);
111
+ if (!resolved.ok) {
112
+ return {
113
+ type: "sdlc_commit_prep",
114
+ version: "1",
115
+ ok: false,
116
+ projectRoot,
117
+ topogramRoot,
118
+ changedFiles: files,
119
+ taskFiles: [],
120
+ changedTasks: [],
121
+ openTasks: [],
122
+ warnings: [],
123
+ errors: ["workspace resolution failed"]
124
+ };
125
+ }
126
+
127
+ const taskFiles = taskFilesFromAst(ast, projectRoot);
128
+ const changedTaskFiles = [...taskFiles.values()]
129
+ .filter((entry) => changedFileSet.has(entry.file))
130
+ .map((entry) => entry.file)
131
+ .sort();
132
+ const taskIds = new Set(changedTaskFiles.flatMap((file) => taskFiles.get(file)?.ids || []));
133
+ const tasksById = new Map((resolved.graph.byKind.task || []).map(/** @param {Record<string, any>} task */ (task) => [task.id, task]));
134
+ const resolvedTasks = /** @type {Record<string, any>[]} */ ([...taskIds]
135
+ .map((id) => tasksById.get(id))
136
+ .filter(Boolean));
137
+ const changedTasks = resolvedTasks
138
+ .map((task) => {
139
+ const source = [...taskFiles.values()].find((entry) => entry.ids.includes(task.id))?.file || null;
140
+ const disposition = task.disposition || (["claimed", "in-progress"].includes(String(task.status)) ? "active" : null);
141
+ return {
142
+ id: task.id,
143
+ status: task.status,
144
+ priority: task.priority || null,
145
+ disposition,
146
+ explicitDisposition: Boolean(task.disposition),
147
+ file: source,
148
+ requiresDisposition: needsExplicitDisposition(task)
149
+ };
150
+ })
151
+ .sort((a, b) => a.id.localeCompare(b.id));
152
+ const openTasks = changedTasks.filter((task) => task.status !== "done");
153
+ const errors = [];
154
+ const warnings = [];
155
+
156
+ for (const task of openTasks) {
157
+ if (task.requiresDisposition) {
158
+ errors.push(`Open task ${task.id} in ${task.file} needs explicit disposition active|follow_up|deferred|backlog|blocker before commit.`);
159
+ }
160
+ if (task.disposition === "blocker") {
161
+ errors.push(`Open task ${task.id} is marked disposition blocker.`);
162
+ }
163
+ if (task.priority === "high" && ["follow_up", "deferred", "backlog"].includes(String(task.disposition))) {
164
+ warnings.push(`High priority task ${task.id} remains open as ${task.disposition}.`);
165
+ }
166
+ }
167
+
168
+ return {
169
+ type: "sdlc_commit_prep",
170
+ version: "1",
171
+ ok: errors.length === 0,
172
+ projectRoot,
173
+ topogramRoot,
174
+ changedFiles: files,
175
+ taskFiles: changedTaskFiles,
176
+ changedTasks,
177
+ openTasks,
178
+ warnings,
179
+ errors,
180
+ nextCommands: [
181
+ "topogram sdlc explain <task-id> --json",
182
+ "topogram query slice ./topo --task <task-id> --json",
183
+ "topogram sdlc gate . --base <ref> --head <ref> --require-adopted"
184
+ ]
185
+ };
186
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
- import { resolveTopoRoot } from "../workspace-paths.js";
6
+ import { sdlcRootForSdlc } from "./paths.js";
7
7
 
8
8
  const TEMPLATES = {
9
9
  pitch: (slug) => `pitch pitch_${slug} {
@@ -41,8 +41,10 @@ const TEMPLATES = {
41
41
  description "What the agent or human will do."
42
42
  satisfies []
43
43
  acceptance_refs []
44
+ verification_refs []
44
45
  affects []
45
46
  blocked_by []
47
+ disposition active
46
48
  priority medium
47
49
  work_type implementation
48
50
  status unclaimed
@@ -74,7 +76,7 @@ export function scaffoldNew(workspaceRoot, kind, slug) {
74
76
  if (!slug || !/^[a-z][a-z0-9_]*$/.test(slug)) {
75
77
  return { ok: false, error: `Invalid slug '${slug}' — must match /^[a-z][a-z0-9_]*$/` };
76
78
  }
77
- const targetDir = path.join(resolveTopoRoot(workspaceRoot), `${kind === "acceptance_criterion" ? "acceptance_criteria" : kind + "s"}`);
79
+ const targetDir = path.join(sdlcRootForSdlc(workspaceRoot), `${kind === "acceptance_criterion" ? "acceptance_criteria" : kind + "s"}`);
78
80
  if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
79
81
  const targetFile = path.join(targetDir, `${slug}.tg`);
80
82
  if (existsSync(targetFile)) {
@@ -30,6 +30,8 @@ export function defaultActiveStatuses(kind) {
30
30
  return new Set(["draft", "approved", "superseded"]);
31
31
  case "task":
32
32
  return new Set(["unclaimed", "claimed", "in-progress", "blocked"]);
33
+ case "plan":
34
+ return new Set(["draft", "active"]);
33
35
  case "bug":
34
36
  return new Set(["open", "in-progress", "fixed"]);
35
37
  case "document":
@@ -4,6 +4,7 @@ import * as pitch from "./pitch.js";
4
4
  import * as requirement from "./requirement.js";
5
5
  import * as acceptanceCriterion from "./acceptance-criterion.js";
6
6
  import * as task from "./task.js";
7
+ import * as plan from "./plan.js";
7
8
  import * as bug from "./bug.js";
8
9
  import * as document from "./document.js";
9
10
 
@@ -12,6 +13,7 @@ const MODULES = {
12
13
  requirement,
13
14
  acceptance_criterion: acceptanceCriterion,
14
15
  task,
16
+ plan,
15
17
  bug,
16
18
  document
17
19
  };
@@ -51,6 +53,7 @@ export {
51
53
  requirement,
52
54
  acceptanceCriterion,
53
55
  task,
56
+ plan,
54
57
  bug,
55
58
  document
56
59
  };
@@ -0,0 +1,32 @@
1
+ // Plan state machine.
2
+ //
3
+ // draft → active → complete
4
+ // └────→ superseded
5
+ //
6
+ // `complete` and `superseded` are archive-eligible. Plans are optional
7
+ // support artifacts; their state machine tracks the plan artifact, not the
8
+ // owning task's DoD.
9
+
10
+ export const LEGAL_TRANSITIONS = {
11
+ draft: ["active", "superseded"],
12
+ active: ["complete", "superseded", "draft"],
13
+ complete: [],
14
+ superseded: []
15
+ };
16
+
17
+ export const TERMINAL_STATUSES = new Set(["complete", "superseded"]);
18
+ export const ARCHIVABLE_STATUSES = new Set(["complete", "superseded"]);
19
+
20
+ export function validateTransition(from, to) {
21
+ const allowed = LEGAL_TRANSITIONS[from];
22
+ if (!allowed) {
23
+ return { ok: false, error: `Unknown plan status '${from}'` };
24
+ }
25
+ if (!allowed.includes(to)) {
26
+ return {
27
+ ok: false,
28
+ error: `Plan cannot transition from '${from}' to '${to}' — allowed: ${allowed.join(", ") || "(terminal)"}`
29
+ };
30
+ }
31
+ return { ok: true };
32
+ }
@@ -197,9 +197,16 @@ function validateFieldShapes(errors, statement, fieldMap) {
197
197
  ensureSingleValueField(errors, statement, fieldMap, "method", ["symbol"]);
198
198
  ensureSingleValueField(errors, statement, fieldMap, "severity", ["symbol"]);
199
199
  ensureSingleValueField(errors, statement, fieldMap, "category", ["symbol"]);
200
+ ensureSingleValueField(errors, statement, fieldMap, "priority", ["symbol"]);
201
+ ensureSingleValueField(errors, statement, fieldMap, "work_type", ["symbol"]);
202
+ ensureSingleValueField(errors, statement, fieldMap, "disposition", ["symbol"]);
203
+ ensureSingleValueField(errors, statement, fieldMap, "task", ["symbol"]);
200
204
  ensureSingleValueField(errors, statement, fieldMap, "version", ["string"]);
205
+ ensureSingleValueField(errors, statement, fieldMap, "updated", ["string"]);
206
+ ensureSingleValueField(errors, statement, fieldMap, "notes", ["string"]);
207
+ ensureSingleValueField(errors, statement, fieldMap, "outcome", ["string"]);
201
208
 
202
- for (const key of [
209
+ const listFields = [
203
210
  "aliases",
204
211
  "excludes",
205
212
  "uses_terms",
@@ -220,7 +227,6 @@ function validateFieldShapes(errors, statement, fieldMap) {
220
227
  "realizes",
221
228
  "outputs",
222
229
  "inputs",
223
- "steps",
224
230
  "validates",
225
231
  "scenarios",
226
232
  "observes",
@@ -233,11 +239,19 @@ function validateFieldShapes(errors, statement, fieldMap) {
233
239
  "lookups",
234
240
  "dependencies",
235
241
  "approvals"
236
- ]) {
242
+ ];
243
+ if (statement.kind === "orchestration") {
244
+ listFields.push("steps");
245
+ }
246
+ for (const key of listFields) {
237
247
  ensureSingleValueField(errors, statement, fieldMap, key, ["list"]);
238
248
  }
239
249
 
240
- for (const key of ["fields", "props", "events", "slots", "behaviors", "keys", "relations", "invariants", "rename", "overrides", "endpoints", "error_responses", "wire_fields", "responses", "preconditions", "idempotency", "cache", "delete_semantics", "async_jobs", "async_status", "downloads", "authorization", "callbacks", "screens", "collection_views", "screen_actions", "visibility_rules", "field_lookups", "screen_routes", "web_hints", "ios_hints", "app_shell", "navigation", "screen_regions", "widget_bindings", "design_tokens", "tables", "columns", "keys", "indexes", "relations", "lifecycle", "generator_defaults"]) {
250
+ const blockFields = ["fields", "props", "events", "slots", "behaviors", "keys", "relations", "invariants", "rename", "overrides", "endpoints", "error_responses", "wire_fields", "responses", "preconditions", "idempotency", "cache", "delete_semantics", "async_jobs", "async_status", "downloads", "authorization", "callbacks", "commands", "command_options", "command_outputs", "command_effects", "command_examples", "screens", "collection_views", "screen_actions", "visibility_rules", "field_lookups", "screen_routes", "web_hints", "ios_hints", "app_shell", "navigation", "screen_regions", "widget_bindings", "design_tokens", "tables", "columns", "keys", "indexes", "relations", "lifecycle", "generator_defaults"];
251
+ if (statement.kind === "plan") {
252
+ blockFields.push("steps");
253
+ }
254
+ for (const key of blockFields) {
241
255
  ensureSingleValueField(errors, statement, fieldMap, key, ["block"]);
242
256
  }
243
257
 
@@ -267,6 +281,11 @@ function validateFieldShapes(errors, statement, fieldMap) {
267
281
  validateBlockEntryLengths(errors, statement, fieldMap, "downloads", 7);
268
282
  validateBlockEntryLengths(errors, statement, fieldMap, "authorization", 3);
269
283
  validateBlockEntryLengths(errors, statement, fieldMap, "callbacks", 11);
284
+ validateBlockEntryLengths(errors, statement, fieldMap, "commands", 2);
285
+ validateBlockEntryLengths(errors, statement, fieldMap, "command_options", 6);
286
+ validateBlockEntryLengths(errors, statement, fieldMap, "command_outputs", 4);
287
+ validateBlockEntryLengths(errors, statement, fieldMap, "command_effects", 4);
288
+ validateBlockEntryLengths(errors, statement, fieldMap, "command_examples", 4);
270
289
  validateBlockEntryLengths(errors, statement, fieldMap, "screens", 4);
271
290
  validateBlockEntryLengths(errors, statement, fieldMap, "collection_views", 4);
272
291
  validateBlockEntryLengths(errors, statement, fieldMap, "screen_actions", 6);
@@ -416,11 +435,13 @@ function validateReferenceKinds(errors, statement, fieldMap, registry) {
416
435
  introduces_decisions: ["decision"],
417
436
  satisfies: ["requirement", "acceptance_criterion"],
418
437
  acceptance_refs: ["acceptance_criterion"],
438
+ verification_refs: ["verification"],
419
439
  requirement_refs: ["requirement"],
420
440
  fixes_bugs: ["bug"],
421
441
  blocks: ["task"],
422
442
  blocked_by: ["task"],
423
443
  claimed_by: ["actor", "role"],
444
+ task: ["task"],
424
445
  violates: ["rule"],
425
446
  surfaces_rule: ["rule"],
426
447
  introduced_in: ["task", "bug"],
@@ -5,6 +5,7 @@ import { validateExpressions } from "./expressions.js";
5
5
  import { validateApiHttpProjection } from "./projections/api-http.js";
6
6
  import { validateDbProjection } from "./projections/db.js";
7
7
  import { validateProjectionGeneratorDefaults } from "./projections/generator-defaults.js";
8
+ import { validateCliProjection } from "./projections/cli.js";
8
9
  import { validateUiProjection } from "./projections/ui.js";
9
10
  import { buildRegistry } from "./registry.js";
10
11
  import {
@@ -17,6 +18,7 @@ import { validatePitch } from "./per-kind/pitch.js";
17
18
  import { validateRequirement } from "./per-kind/requirement.js";
18
19
  import { validateAcceptanceCriterion } from "./per-kind/acceptance-criterion.js";
19
20
  import { validateTask } from "./per-kind/task.js";
21
+ import { validatePlan } from "./per-kind/plan.js";
20
22
  import { validateBug } from "./per-kind/bug.js";
21
23
 
22
24
  export {
@@ -28,17 +30,23 @@ export {
28
30
  REQUIREMENT_IDENTIFIER_PATTERN,
29
31
  ACCEPTANCE_CRITERION_IDENTIFIER_PATTERN,
30
32
  TASK_IDENTIFIER_PATTERN,
33
+ PLAN_IDENTIFIER_PATTERN,
31
34
  BUG_IDENTIFIER_PATTERN,
32
35
  DOCUMENT_IDENTIFIER_PATTERN,
33
36
  GLOBAL_STATUSES,
34
37
  DECISION_STATUSES,
35
38
  RULE_SEVERITIES,
36
39
  VERIFICATION_METHODS,
40
+ CLI_COMMAND_EFFECTS,
41
+ CLI_COMMAND_OPTION_TYPES,
42
+ CLI_COMMAND_OUTPUT_FORMATS,
37
43
  STATUS_SETS_BY_KIND,
38
44
  PITCH_STATUSES,
39
45
  REQUIREMENT_STATUSES,
40
46
  ACCEPTANCE_CRITERION_STATUSES,
41
47
  TASK_STATUSES,
48
+ PLAN_STATUSES,
49
+ PLAN_STEP_STATUSES,
42
50
  BUG_STATUSES,
43
51
  PRIORITY_VALUES,
44
52
  WORK_TYPES,
@@ -91,6 +99,7 @@ export function validateWorkspace(workspaceAst) {
91
99
  validateReferenceRules(errors, statement, fieldMap, registry);
92
100
  validateDataModelStatement(errors, statement, fieldMap, registry);
93
101
  validateApiHttpProjection(errors, statement, fieldMap, registry);
102
+ validateCliProjection(errors, statement, fieldMap, registry);
94
103
  validateUiProjection(errors, statement, fieldMap, registry);
95
104
  validateDbProjection(errors, statement, fieldMap, registry);
96
105
  validateProjectionGeneratorDefaults(errors, statement, fieldMap);
@@ -101,6 +110,7 @@ export function validateWorkspace(workspaceAst) {
101
110
  validateRequirement(errors, statement, fieldMap, registry);
102
111
  validateAcceptanceCriterion(errors, statement, fieldMap, registry);
103
112
  validateTask(errors, statement, fieldMap, registry);
113
+ validatePlan(errors, statement, fieldMap, registry);
104
114
  validateBug(errors, statement, fieldMap, registry);
105
115
  validateExpressions(errors, statement, fieldMap);
106
116
  }
@@ -6,17 +6,24 @@ export const PITCH_IDENTIFIER_PATTERN: RegExp;
6
6
  export const REQUIREMENT_IDENTIFIER_PATTERN: RegExp;
7
7
  export const ACCEPTANCE_CRITERION_IDENTIFIER_PATTERN: RegExp;
8
8
  export const TASK_IDENTIFIER_PATTERN: RegExp;
9
+ export const PLAN_IDENTIFIER_PATTERN: RegExp;
9
10
  export const BUG_IDENTIFIER_PATTERN: RegExp;
10
11
  export const DOCUMENT_IDENTIFIER_PATTERN: RegExp;
11
12
  export const GLOBAL_STATUSES: Set<string>;
12
13
  export const DECISION_STATUSES: Set<string>;
13
14
  export const RULE_SEVERITIES: Set<string>;
14
15
  export const VERIFICATION_METHODS: Set<string>;
16
+ export const CLI_COMMAND_EFFECTS: Set<string>;
17
+ export const CLI_COMMAND_OPTION_TYPES: Set<string>;
18
+ export const CLI_COMMAND_OUTPUT_FORMATS: Set<string>;
15
19
  export const STATUS_SETS_BY_KIND: Record<string, Set<string>>;
16
20
  export const PITCH_STATUSES: Set<string>;
17
21
  export const REQUIREMENT_STATUSES: Set<string>;
18
22
  export const ACCEPTANCE_CRITERION_STATUSES: Set<string>;
19
23
  export const TASK_STATUSES: Set<string>;
24
+ export const TASK_DISPOSITIONS: Set<string>;
25
+ export const PLAN_STATUSES: Set<string>;
26
+ export const PLAN_STEP_STATUSES: Set<string>;
20
27
  export const BUG_STATUSES: Set<string>;
21
28
  export const PRIORITY_VALUES: Set<string>;
22
29
  export const WORK_TYPES: Set<string>;
@@ -20,6 +20,7 @@ export const STATEMENT_KINDS = new Set([
20
20
  "requirement",
21
21
  "acceptance_criterion",
22
22
  "task",
23
+ "plan",
23
24
  "bug"
24
25
  ]);
25
26
 
@@ -29,6 +30,7 @@ export const PITCH_IDENTIFIER_PATTERN = /^pitch_[a-z][a-z0-9_]*$/;
29
30
  export const REQUIREMENT_IDENTIFIER_PATTERN = /^req_[a-z][a-z0-9_]*$/;
30
31
  export const ACCEPTANCE_CRITERION_IDENTIFIER_PATTERN = /^ac_[a-z][a-z0-9_]*$/;
31
32
  export const TASK_IDENTIFIER_PATTERN = /^task_[a-z][a-z0-9_]*$/;
33
+ export const PLAN_IDENTIFIER_PATTERN = /^plan_[a-z][a-z0-9_]*$/;
32
34
  export const BUG_IDENTIFIER_PATTERN = /^bug_[a-z][a-z0-9_]*$/;
33
35
  export const DOCUMENT_IDENTIFIER_PATTERN = /^doc_[a-z][a-z0-9_]*$/;
34
36
 
@@ -41,7 +43,10 @@ export const PITCH_STATUSES = new Set(["draft", "shaped", "submitted", "approved
41
43
  export const REQUIREMENT_STATUSES = new Set(["draft", "in-review", "approved", "superseded"]);
42
44
  export const ACCEPTANCE_CRITERION_STATUSES = new Set(["draft", "approved", "superseded"]);
43
45
  export const TASK_STATUSES = new Set(["unclaimed", "claimed", "in-progress", "done", "blocked"]);
46
+ export const PLAN_STATUSES = new Set(["draft", "active", "complete", "superseded"]);
47
+ export const PLAN_STEP_STATUSES = new Set(["pending", "in-progress", "blocked", "done", "skipped"]);
44
48
  export const BUG_STATUSES = new Set(["open", "in-progress", "fixed", "verified", "wont-fix"]);
49
+ export const TASK_DISPOSITIONS = new Set(["active", "follow_up", "deferred", "backlog", "blocker"]);
45
50
 
46
51
  export const PRIORITY_VALUES = new Set(["critical", "high", "medium", "low"]);
47
52
  export const WORK_TYPES = new Set([
@@ -73,9 +78,13 @@ export const STATUS_SETS_BY_KIND = {
73
78
  requirement: REQUIREMENT_STATUSES,
74
79
  acceptance_criterion: ACCEPTANCE_CRITERION_STATUSES,
75
80
  task: TASK_STATUSES,
81
+ plan: PLAN_STATUSES,
76
82
  bug: BUG_STATUSES
77
83
  };
78
84
  export const VERIFICATION_METHODS = new Set(["smoke", "runtime", "contract", "journey", "manual"]);
85
+ export const CLI_COMMAND_EFFECTS = new Set(["read_only", "writes_workspace", "writes_app", "network", "package_install", "git", "filesystem"]);
86
+ export const CLI_COMMAND_OPTION_TYPES = new Set(["string", "boolean", "number", "integer", "enum", "path", "list"]);
87
+ export const CLI_COMMAND_OUTPUT_FORMATS = new Set(["json", "human", "file", "exit_code"]);
79
88
 
80
89
  export {
81
90
  UI_APP_SHELL_KINDS,
@@ -114,6 +123,7 @@ export const DOMAIN_TAGGABLE_KINDS = new Set([
114
123
  "pitch",
115
124
  "requirement",
116
125
  "task",
126
+ "plan",
117
127
  "bug"
118
128
  ]);
119
129
 
@@ -179,6 +189,11 @@ export const FIELD_SPECS = {
179
189
  "downloads",
180
190
  "authorization",
181
191
  "callbacks",
192
+ "commands",
193
+ "command_options",
194
+ "command_outputs",
195
+ "command_effects",
196
+ "command_examples",
182
197
  "screens",
183
198
  "collection_views",
184
199
  "screen_actions",
@@ -277,9 +292,11 @@ export const FIELD_SPECS = {
277
292
  "status",
278
293
  "priority",
279
294
  "work_type",
295
+ "disposition",
280
296
  "affects",
281
297
  "satisfies",
282
298
  "acceptance_refs",
299
+ "verification_refs",
283
300
  "blocks",
284
301
  "blocked_by",
285
302
  "claimed_by",
@@ -291,6 +308,21 @@ export const FIELD_SPECS = {
291
308
  "updated"
292
309
  ]
293
310
  },
311
+ plan: {
312
+ required: ["name", "description", "task", "status", "steps"],
313
+ allowed: [
314
+ "name",
315
+ "description",
316
+ "task",
317
+ "status",
318
+ "priority",
319
+ "notes",
320
+ "outcome",
321
+ "steps",
322
+ "domain",
323
+ "updated"
324
+ ]
325
+ },
294
326
  bug: {
295
327
  required: ["name", "description", "status", "severity", "priority"],
296
328
  allowed: [
@@ -0,0 +1,128 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ PLAN_IDENTIFIER_PATTERN,
5
+ PLAN_STEP_STATUSES,
6
+ PRIORITY_VALUES
7
+ } from "../kinds.js";
8
+ import {
9
+ blockEntries,
10
+ pushError,
11
+ symbolValue
12
+ } from "../utils.js";
13
+ import { parsePlanStepEntry } from "../../sdlc/plan-steps.js";
14
+
15
+ /** @param {ValidationErrors} errors @param {TopogramStatement} statement */
16
+ function validatePlanIdentifier(errors, statement) {
17
+ if (!PLAN_IDENTIFIER_PATTERN.test(statement.id)) {
18
+ pushError(
19
+ errors,
20
+ `Plan identifier '${statement.id}' must match ${PLAN_IDENTIFIER_PATTERN.source}`,
21
+ statement.loc
22
+ );
23
+ }
24
+ }
25
+
26
+ /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap */
27
+ function validatePriority(errors, statement, fieldMap) {
28
+ const field = fieldMap.get("priority")?.[0];
29
+ if (!field) return;
30
+ if (field.value.type !== "symbol") {
31
+ pushError(errors, `Field 'priority' on plan ${statement.id} must be a symbol`, field.loc);
32
+ return;
33
+ }
34
+ if (!PRIORITY_VALUES.has(field.value.value)) {
35
+ pushError(errors, `Invalid priority '${field.value.value}' on plan ${statement.id}`, field.loc);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {ValidationErrors} errors
41
+ * @param {TopogramStatement} statement
42
+ * @param {TopogramFieldMap} fieldMap
43
+ * @param {TopogramRegistry} registry
44
+ * @returns {void}
45
+ */
46
+ function validatePlanTask(errors, statement, fieldMap, registry) {
47
+ const field = fieldMap.get("task")?.[0];
48
+ if (!field || field.value.type !== "symbol") return;
49
+ const target = registry.get(field.value.value);
50
+ if (!target) return;
51
+ if (target.kind !== "task") {
52
+ pushError(errors, `Plan ${statement.id} task must reference a task, found ${target.kind} '${target.id}'`, field.loc);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {ValidationErrors} errors
58
+ * @param {TopogramStatement} statement
59
+ * @param {TopogramFieldMap} fieldMap
60
+ * @returns {void}
61
+ */
62
+ function validateStepRows(errors, statement, fieldMap) {
63
+ const field = fieldMap.get("steps")?.[0];
64
+ if (!field || field.value.type !== "block") return;
65
+
66
+ const seen = new Set();
67
+ const entries = blockEntries(field.value);
68
+ if (entries.length === 0) {
69
+ pushError(errors, `Plan ${statement.id} must include at least one step`, field.loc);
70
+ }
71
+ for (const entry of entries) {
72
+ const [kindToken, idToken, ...rest] = entry.items;
73
+ if (kindToken?.type !== "symbol" || kindToken.value !== "step") {
74
+ pushError(errors, `Each 'steps' entry on plan ${statement.id} must start with 'step'`, entry.loc);
75
+ continue;
76
+ }
77
+ if (idToken?.type !== "symbol" || !/^[a-z][a-z0-9_]*$/.test(idToken.value)) {
78
+ pushError(errors, `Plan ${statement.id} step id must be a symbol matching /^[a-z][a-z0-9_]*$/`, idToken?.loc || entry.loc);
79
+ continue;
80
+ }
81
+ if (seen.has(idToken.value)) {
82
+ pushError(errors, `Plan ${statement.id} has duplicate step '${idToken.value}'`, idToken.loc);
83
+ }
84
+ seen.add(idToken.value);
85
+
86
+ if (rest.length % 2 !== 0) {
87
+ pushError(errors, `Plan ${statement.id} step '${idToken.value}' fields must be key/value pairs`, entry.loc);
88
+ }
89
+
90
+ const allowedKeys = new Set(["status", "description", "notes", "outcome"]);
91
+ for (let index = 0; index < rest.length - 1; index += 2) {
92
+ const keyToken = rest[index];
93
+ if (keyToken?.type !== "symbol" || !allowedKeys.has(keyToken.value)) {
94
+ pushError(errors, `Plan ${statement.id} step '${idToken.value}' has unsupported field '${keyToken?.value || "unknown"}'`, keyToken?.loc || entry.loc);
95
+ }
96
+ }
97
+
98
+ const parsed = parsePlanStepEntry(entry);
99
+ if (!parsed.status) {
100
+ pushError(errors, `Plan ${statement.id} step '${idToken.value}' requires status`, entry.loc);
101
+ } else if (!PLAN_STEP_STATUSES.has(parsed.status)) {
102
+ pushError(errors, `Invalid step status '${parsed.status}' on plan ${statement.id} step '${idToken.value}'`, entry.loc);
103
+ }
104
+ if (!parsed.description) {
105
+ pushError(errors, `Plan ${statement.id} step '${idToken.value}' requires description`, entry.loc);
106
+ }
107
+ }
108
+ }
109
+
110
+ /** @param {ValidationErrors} errors @param {TopogramStatement} statement @param {TopogramFieldMap} fieldMap @param {TopogramRegistry} registry */
111
+ export function validatePlan(errors, statement, fieldMap, registry) {
112
+ if (statement.kind !== "plan") {
113
+ return;
114
+ }
115
+ validatePlanIdentifier(errors, statement);
116
+ validatePriority(errors, statement, fieldMap);
117
+ validatePlanTask(errors, statement, fieldMap, registry);
118
+ validateStepRows(errors, statement, fieldMap);
119
+
120
+ const status = symbolValue(fieldMap.get("status")?.[0]?.value);
121
+ if (status === "complete") {
122
+ const steps = blockEntries(fieldMap.get("steps")?.[0]?.value).map((entry) => parsePlanStepEntry(entry));
123
+ const incomplete = steps.filter((step) => step.status !== "done" && step.status !== "skipped");
124
+ if (incomplete.length > 0) {
125
+ pushError(errors, `Plan ${statement.id} status 'complete' requires all steps to be done or skipped`, fieldMap.get("status")?.[0]?.loc || statement.loc);
126
+ }
127
+ }
128
+ }