mdkg 0.3.6 → 0.3.8

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 (68) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/CLI_COMMAND_MATRIX.md +132 -24
  3. package/README.md +80 -25
  4. package/dist/cli.js +193 -36
  5. package/dist/command-contract.json +882 -122
  6. package/dist/commands/bundle.js +2 -0
  7. package/dist/commands/capability.js +1 -0
  8. package/dist/commands/checkpoint.js +139 -1
  9. package/dist/commands/db.js +8 -0
  10. package/dist/commands/doctor.js +7 -7
  11. package/dist/commands/format.js +155 -0
  12. package/dist/commands/goal.js +60 -0
  13. package/dist/commands/graph.js +148 -0
  14. package/dist/commands/handoff.js +301 -0
  15. package/dist/commands/mcp.js +9 -0
  16. package/dist/commands/new.js +12 -3
  17. package/dist/commands/pack.js +3 -1
  18. package/dist/commands/query_output.js +2 -0
  19. package/dist/commands/show.js +8 -0
  20. package/dist/commands/spec.js +76 -17
  21. package/dist/commands/status.js +1 -0
  22. package/dist/commands/subgraph.js +7 -4
  23. package/dist/commands/task.js +2 -0
  24. package/dist/commands/upgrade.js +128 -3
  25. package/dist/commands/validate.js +268 -6
  26. package/dist/commands/work.js +176 -5
  27. package/dist/core/project_db_queue_contract.js +101 -0
  28. package/dist/graph/agent_file_types.js +59 -20
  29. package/dist/graph/capabilities_indexer.js +45 -3
  30. package/dist/graph/edges.js +15 -0
  31. package/dist/graph/frontmatter.js +4 -1
  32. package/dist/graph/indexer.js +13 -0
  33. package/dist/graph/node.js +12 -1
  34. package/dist/graph/sqlite_index.js +2 -0
  35. package/dist/graph/subgraphs.js +2 -0
  36. package/dist/graph/template_schema.js +37 -17
  37. package/dist/graph/validate_graph.js +16 -5
  38. package/dist/graph/visibility.js +6 -0
  39. package/dist/init/AGENT_START.md +9 -6
  40. package/dist/init/CLI_COMMAND_MATRIX.md +50 -16
  41. package/dist/init/README.md +26 -9
  42. package/dist/init/init-manifest.json +67 -12
  43. package/dist/init/templates/default/bug.md +2 -0
  44. package/dist/init/templates/default/chk.md +3 -0
  45. package/dist/init/templates/default/epic.md +2 -0
  46. package/dist/init/templates/default/feat.md +2 -0
  47. package/dist/init/templates/default/goal.md +3 -0
  48. package/dist/init/templates/default/manifest.md +45 -0
  49. package/dist/init/templates/default/spike.md +2 -0
  50. package/dist/init/templates/default/task.md +2 -0
  51. package/dist/init/templates/default/test.md +2 -0
  52. package/dist/init/templates/specs/agent.MANIFEST.md +80 -0
  53. package/dist/init/templates/specs/api.MANIFEST.md +33 -0
  54. package/dist/init/templates/specs/base.MANIFEST.md +120 -0
  55. package/dist/init/templates/specs/capability.MANIFEST.md +45 -0
  56. package/dist/init/templates/specs/integration.MANIFEST.md +25 -0
  57. package/dist/init/templates/specs/model.MANIFEST.md +21 -0
  58. package/dist/init/templates/specs/project.MANIFEST.md +39 -0
  59. package/dist/init/templates/specs/runtime-agent.MANIFEST.md +49 -0
  60. package/dist/init/templates/specs/runtime-image.MANIFEST.md +21 -0
  61. package/dist/init/templates/specs/tool.MANIFEST.md +25 -0
  62. package/dist/pack/export_json.js +20 -8
  63. package/dist/pack/export_md.js +15 -4
  64. package/dist/pack/export_xml.js +9 -4
  65. package/dist/pack/metrics.js +12 -4
  66. package/dist/pack/pack.js +9 -1
  67. package/dist/util/argparse.js +3 -0
  68. package/package.json +21 -3
@@ -349,6 +349,8 @@ function publicFilteringErrors(index, includedQids) {
349
349
  node.edges.relates,
350
350
  node.edges.blocked_by,
351
351
  node.edges.blocks,
352
+ node.edges.context_refs ?? [],
353
+ node.edges.evidence_refs ?? [],
352
354
  ]) {
353
355
  for (const target of targets) {
354
356
  if (index.nodes[target] && !includedQids.has(target)) {
@@ -79,6 +79,7 @@ function capabilitySearchText(record) {
79
79
  ...record.aliases,
80
80
  ...record.links,
81
81
  ...record.headings.map((heading) => heading.text),
82
+ JSON.stringify(record.manifest ?? {}),
82
83
  JSON.stringify(record.spec ?? {}),
83
84
  JSON.stringify(record.work ?? {}),
84
85
  JSON.stringify(record.linkage ?? {}),
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CHECKPOINT_KINDS = void 0;
6
7
  exports.createCheckpoint = createCheckpoint;
7
8
  exports.runCheckpointNewCommand = runCheckpointNewCommand;
8
9
  const fs_1 = __importDefault(require("fs"));
@@ -17,6 +18,20 @@ const atomic_1 = require("../util/atomic");
17
18
  const lock_1 = require("../util/lock");
18
19
  const sqlite_index_1 = require("../graph/sqlite_index");
19
20
  const event_support_1 = require("./event_support");
21
+ exports.CHECKPOINT_KINDS = [
22
+ "implementation",
23
+ "test-proof",
24
+ "goal-closeout",
25
+ "audit",
26
+ "handoff",
27
+ ];
28
+ function normalizeCheckpointKind(value) {
29
+ const normalized = (value ?? "implementation").toLowerCase();
30
+ if (exports.CHECKPOINT_KINDS.includes(normalized)) {
31
+ return normalized;
32
+ }
33
+ throw new errors_1.UsageError(`--kind must be one of ${exports.CHECKPOINT_KINDS.join(", ")}`);
34
+ }
20
35
  function parseCsvList(raw) {
21
36
  if (!raw) {
22
37
  return [];
@@ -83,6 +98,125 @@ function normalizeWorkspace(value) {
83
98
  }
84
99
  return normalized;
85
100
  }
101
+ function kindSpecificSection(kind) {
102
+ switch (kind) {
103
+ case "implementation":
104
+ return [
105
+ "# Implementation Details",
106
+ "",
107
+ "- Code or graph surfaces changed:",
108
+ "- Architecture or data-shape notes:",
109
+ "- Compatibility notes:",
110
+ ];
111
+ case "test-proof":
112
+ return [
113
+ "# Test Proof",
114
+ "",
115
+ "- Test target:",
116
+ "- Fixtures or temp repos:",
117
+ "- Coverage gaps:",
118
+ ];
119
+ case "goal-closeout":
120
+ return [
121
+ "# Goal Closeout",
122
+ "",
123
+ "- Goal condition result:",
124
+ "- Scoped nodes closed:",
125
+ "- Remaining deferred work:",
126
+ ];
127
+ case "audit":
128
+ return [
129
+ "# Audit Findings",
130
+ "",
131
+ "- Reviewed surfaces:",
132
+ "- Findings:",
133
+ "- Residual risk:",
134
+ ];
135
+ case "handoff":
136
+ return [
137
+ "# Handoff Summary",
138
+ "",
139
+ "- Recipient/context:",
140
+ "- Starting node or command:",
141
+ "- Explicit boundaries:",
142
+ ];
143
+ }
144
+ }
145
+ function checkpointBody(kind) {
146
+ return [
147
+ "# Summary",
148
+ "",
149
+ "What was completed in this phase? What is now true?",
150
+ "",
151
+ "# Scope Covered",
152
+ "",
153
+ "Keep `scope` frontmatter updated when possible.",
154
+ "",
155
+ "## Changed Surfaces",
156
+ "",
157
+ "- files, commands, nodes, docs, or runtime surfaces changed",
158
+ "",
159
+ "## Boundaries",
160
+ "",
161
+ "- in scope:",
162
+ "- out of scope:",
163
+ "- raw secrets, raw prompts, raw payloads, and bulky execution traces excluded:",
164
+ "",
165
+ "# Decisions Captured",
166
+ "",
167
+ "Link the most important decision records.",
168
+ "",
169
+ "# Implementation Summary",
170
+ "",
171
+ "What changed? What patterns or architecture emerged?",
172
+ "",
173
+ ...kindSpecificSection(kind),
174
+ "",
175
+ "# Verification / Testing",
176
+ "",
177
+ "## Command Evidence",
178
+ "",
179
+ "- command:",
180
+ "- result:",
181
+ "",
182
+ "## Pass / Fail Status",
183
+ "",
184
+ "- status:",
185
+ "",
186
+ "## Known Warnings",
187
+ "",
188
+ "- warning:",
189
+ "",
190
+ "# Known Issues / Follow-ups",
191
+ "",
192
+ "- issue 1",
193
+ "- issue 2",
194
+ "",
195
+ "## Follow-up Refs",
196
+ "",
197
+ "- task/test/goal refs:",
198
+ "",
199
+ "# Links / Artifacts",
200
+ "",
201
+ "- packs",
202
+ "- PRs/commits",
203
+ "- docs",
204
+ "- dashboards",
205
+ "",
206
+ "# Raw Content Safety",
207
+ "",
208
+ "- Summarize evidence and use refs, hashes, and artifact links instead of raw secrets, raw prompts, raw payloads, or bulky execution traces.",
209
+ "",
210
+ ].join("\n");
211
+ }
212
+ function replaceRenderedBody(content, body) {
213
+ const marker = "\n---\n";
214
+ const start = content.indexOf(marker);
215
+ if (!content.startsWith("---\n") || start === -1) {
216
+ return content;
217
+ }
218
+ return `${content.slice(0, start + marker.length)}${body}`;
219
+ }
86
220
  function createCheckpointLocked(options) {
87
221
  const title = options.title.trim();
88
222
  if (!title) {
@@ -130,12 +264,14 @@ function createCheckpointLocked(options) {
130
264
  }
131
265
  }
132
266
  const scope = parseCsvList(options.scope).map((value) => normalizeId(value, "--scope"));
267
+ const kind = normalizeCheckpointKind(options.kind);
133
268
  const now = options.now ?? new Date();
134
269
  const today = (0, date_1.formatDate)(now);
135
270
  const template = (0, loader_1.loadTemplate)(options.root, config, "checkpoint", options.template);
136
271
  const content = (0, loader_1.renderTemplate)(template, {
137
272
  id,
138
273
  title,
274
+ checkpoint_kind: kind,
139
275
  status,
140
276
  priority,
141
277
  created: today,
@@ -143,8 +279,9 @@ function createCheckpointLocked(options) {
143
279
  relates,
144
280
  scope,
145
281
  });
282
+ const rendered = replaceRenderedBody(content, checkpointBody(kind));
146
283
  try {
147
- (0, atomic_1.writeFileExclusive)(filePath, content);
284
+ (0, atomic_1.writeFileExclusive)(filePath, rendered);
148
285
  }
149
286
  catch (err) {
150
287
  const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : "";
@@ -168,6 +305,7 @@ function createCheckpointLocked(options) {
168
305
  id,
169
306
  qid: `${ws}:${id}`,
170
307
  path: path_1.default.relative(options.root, filePath),
308
+ kind,
171
309
  };
172
310
  }
173
311
  function createCheckpoint(options) {
@@ -9,6 +9,7 @@ exports.runDbMigrateCommand = runDbMigrateCommand;
9
9
  exports.runDbVerifyCommand = runDbVerifyCommand;
10
10
  exports.runDbStatsCommand = runDbStatsCommand;
11
11
  exports.runDbQueueCreateCommand = runDbQueueCreateCommand;
12
+ exports.runDbQueueContractCommand = runDbQueueContractCommand;
12
13
  exports.runDbQueuePauseCommand = runDbQueuePauseCommand;
13
14
  exports.runDbQueueResumeCommand = runDbQueueResumeCommand;
14
15
  exports.runDbQueueEnqueueCommand = runDbQueueEnqueueCommand;
@@ -36,6 +37,7 @@ const project_db_1 = require("../core/project_db");
36
37
  const project_db_migrations_1 = require("../core/project_db_migrations");
37
38
  const project_db_snapshot_1 = require("../core/project_db_snapshot");
38
39
  const project_db_queue_1 = require("../core/project_db_queue");
40
+ const project_db_queue_contract_1 = require("../core/project_db_queue_contract");
39
41
  const version_1 = require("../core/version");
40
42
  const index_1 = require("./index");
41
43
  const capabilities_indexer_1 = require("../graph/capabilities_indexer");
@@ -539,6 +541,12 @@ function runDbQueueCreateCommand(options) {
539
541
  reason: options.reason,
540
542
  }), "db-queue-create");
541
543
  }
544
+ function runDbQueueContractCommand(options) {
545
+ writeQueueJsonOrText("db-queue-contract", {
546
+ mdkg_version: (0, version_1.readPackageVersion)(),
547
+ contract: (0, project_db_queue_contract_1.projectDbQueueAdapterContract)(),
548
+ }, options.json);
549
+ }
542
550
  function runDbQueuePauseCommand(options) {
543
551
  runQueueMutation(options, (databasePath) => ({ queue: (0, project_db_queue_1.pauseProjectQueue)(databasePath, { queue_name: requireQueueName(options), reason: options.reason }) }), "db-queue-pause");
544
552
  }
@@ -164,8 +164,8 @@ function runArchiveStorageCheck(root) {
164
164
  name: "archive-storage",
165
165
  ok: true,
166
166
  level: "warn",
167
- detail: `stray uncompressed archive file(s) found without managed sidecars: ${strayRaw.join(", ")}; run \`mdkg archive add <file>\` or move raw files under a managed archive source directory`,
168
- remediation: "Run `mdkg archive add <file>` or move raw files under a managed archive source directory.",
167
+ detail: `stray uncompressed archive file(s) found without managed sidecars: ${strayRaw.join(", ")}; these are storage hygiene warnings, not source defects`,
168
+ remediation: "Either run `mdkg archive add <file>` to create a managed sidecar, move raw files under an existing managed archive source directory, or remove unintended local files before committing.",
169
169
  refs: strayRaw,
170
170
  });
171
171
  }
@@ -255,8 +255,8 @@ function runProjectDbRuntimePolicyCheck(root) {
255
255
  name: "project-db-runtime",
256
256
  ok: true,
257
257
  level: "warn",
258
- detail: `active project DB runtime/transient file(s) are local-only and should not be committed: ${files.join(", ")}`,
259
- remediation: "Keep runtime DB and transient files ignored; commit sealed state only by explicit repo policy.",
258
+ detail: `active project DB runtime/transient file(s) are local-only and should not be committed: ${files.join(", ")}; this is expected local state when \`mdkg db verify\` passes`,
259
+ remediation: "Keep runtime DB and transient files ignored, run `mdkg db verify --json` if DB health is in question, and commit sealed state only by explicit repo policy.",
260
260
  refs: files,
261
261
  });
262
262
  }
@@ -386,7 +386,7 @@ function runSelectedGoalChecks(root, config, options) {
386
386
  ok: true,
387
387
  level: "warn",
388
388
  detail: selected.warning,
389
- remediation: "Run `mdkg goal select <goal-id>` or `mdkg goal clear`.",
389
+ remediation: "Run `mdkg goal clear --json` when no repo-local goal should be selected, or create/select a repo-local active goal only when work is continuing.",
390
390
  strictFail: true,
391
391
  }),
392
392
  ];
@@ -418,7 +418,7 @@ function runSelectedGoalChecks(root, config, options) {
418
418
  ok: true,
419
419
  level: "warn",
420
420
  detail: `selected goal ${selected.state.qid} is missing from the graph`,
421
- remediation: "Run `mdkg goal select <active-goal>` or `mdkg goal clear`.",
421
+ remediation: "Run `mdkg goal clear --json` when no repo-local goal should be selected, or `mdkg goal activate <goal-id> --json` for the next active repo-local goal.",
422
422
  refs: [selected.state.qid],
423
423
  strictFail: true,
424
424
  }),
@@ -433,7 +433,7 @@ function runSelectedGoalChecks(root, config, options) {
433
433
  ok: true,
434
434
  level: "warn",
435
435
  detail: `selected goal ${selected.state.qid} is achieved but still current`,
436
- remediation: "Run `mdkg goal select <active-goal>` or `mdkg goal clear`.",
436
+ remediation: "Run `mdkg goal clear --json` for an achieved current goal, or `mdkg goal activate <goal-id> --json` only when a new repo-local goal should become active. Root orchestrators should not mutate dirty child repos without approval.",
437
437
  refs: [selected.state.qid],
438
438
  strictFail: true,
439
439
  }),
@@ -19,6 +19,7 @@ const id_1 = require("../util/id");
19
19
  const refs_1 = require("../util/refs");
20
20
  const atomic_1 = require("../util/atomic");
21
21
  const lock_1 = require("../util/lock");
22
+ const validate_1 = require("./validate");
22
23
  const DEC_ID_RE = /^dec-[0-9]+$/;
23
24
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
24
25
  const ID_LIST_KEYS = new Set(["refs", "scope"]);
@@ -46,6 +47,62 @@ function isValidId(value) {
46
47
  function isCoreListFile(filePath) {
47
48
  return path_1.default.basename(filePath) === "core.md" && path_1.default.basename(path_1.default.dirname(filePath)) === "core";
48
49
  }
50
+ function normalizeHeading(value) {
51
+ return value.trim().toLowerCase();
52
+ }
53
+ function existingHeadings(body) {
54
+ const headings = new Set();
55
+ for (const line of body.split(/\r?\n/)) {
56
+ const match = /^#+\s+(.*)$/.exec(line);
57
+ if (match) {
58
+ headings.add(normalizeHeading(match[1] ?? ""));
59
+ }
60
+ }
61
+ return headings;
62
+ }
63
+ function appendMissingHeadings(body, headings) {
64
+ if (headings.length === 0) {
65
+ return body;
66
+ }
67
+ const trimmed = body.replace(/\s+$/g, "");
68
+ const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : "";
69
+ return `${prefix}${headings.map((heading) => `# ${heading}\n`).join("\n")}`;
70
+ }
71
+ function countBuckets(values) {
72
+ const counts = new Map();
73
+ for (const value of values) {
74
+ if (!value) {
75
+ continue;
76
+ }
77
+ counts.set(value, (counts.get(value) ?? 0) + 1);
78
+ }
79
+ return Array.from(counts.entries())
80
+ .map(([key, count]) => ({ key, count }))
81
+ .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
82
+ }
83
+ function normalizeLimit(limit) {
84
+ if (limit === undefined) {
85
+ return 50;
86
+ }
87
+ if (!Number.isInteger(limit) || limit < 0) {
88
+ throw new errors_1.ValidationError("--limit must be a non-negative integer");
89
+ }
90
+ return limit;
91
+ }
92
+ function buildHeadingsSummary(changes, limit) {
93
+ const effectiveLimit = limit === null || limit === undefined ? changes.length : limit;
94
+ const emitted = Math.min(changes.length, effectiveLimit);
95
+ return {
96
+ total: changes.length,
97
+ emitted,
98
+ truncated: emitted < changes.length,
99
+ omitted_count: changes.length - emitted,
100
+ limit: limit === undefined ? null : limit,
101
+ affected_file_count: new Set(changes.map((change) => change.path)).size,
102
+ by_node_type: countBuckets(changes.map((change) => change.type)),
103
+ top_paths: countBuckets(changes.map((change) => change.path)).slice(0, 25),
104
+ };
105
+ }
49
106
  function normalizeScalar(value) {
50
107
  return value.trim();
51
108
  }
@@ -250,7 +307,105 @@ function normalizeFrontmatter(frontmatter, schema, type, workStatusEnum, priorit
250
307
  }
251
308
  return { normalized, errors };
252
309
  }
310
+ function runHeadingFormatCommandLocked(options) {
311
+ if (options.dryRun && options.apply) {
312
+ throw new errors_1.ValidationError("format --headings cannot use --dry-run and --apply together");
313
+ }
314
+ const config = (0, config_1.loadConfig)(options.root);
315
+ const filesByAlias = (0, workspace_files_1.listWorkspaceDocFilesByAlias)(options.root, config);
316
+ const errors = [];
317
+ const changes = [];
318
+ for (const files of Object.values(filesByAlias)) {
319
+ for (const filePath of files) {
320
+ if (isCoreListFile(filePath)) {
321
+ continue;
322
+ }
323
+ let content = "";
324
+ try {
325
+ content = fs_1.default.readFileSync(filePath, "utf8");
326
+ }
327
+ catch (err) {
328
+ const message = err instanceof Error ? err.message : "unknown error";
329
+ errors.push(`${filePath}: failed to read file: ${message}`);
330
+ continue;
331
+ }
332
+ let parsed;
333
+ try {
334
+ parsed = (0, frontmatter_1.parseFrontmatter)(content, filePath);
335
+ }
336
+ catch (err) {
337
+ const message = err instanceof Error ? err.message : "unknown error";
338
+ errors.push(message);
339
+ continue;
340
+ }
341
+ const typeValue = parsed.frontmatter.type;
342
+ if (typeof typeValue !== "string") {
343
+ errors.push(`${filePath}: type is required`);
344
+ continue;
345
+ }
346
+ const type = typeValue.toLowerCase();
347
+ const recommended = validate_1.RECOMMENDED_HEADINGS[type];
348
+ if (!recommended) {
349
+ continue;
350
+ }
351
+ if (!node_1.ALLOWED_TYPES.has(type)) {
352
+ errors.push(`${filePath}: type must be one of ${Array.from(node_1.ALLOWED_TYPES).join(", ")}`);
353
+ continue;
354
+ }
355
+ const present = existingHeadings(parsed.body);
356
+ const addedHeadings = recommended.filter((heading) => !present.has(normalizeHeading(heading)));
357
+ if (addedHeadings.length === 0) {
358
+ continue;
359
+ }
360
+ const frontmatterEnd = content.indexOf("---", 3);
361
+ const frontmatterBlock = frontmatterEnd >= 0 ? content.slice(0, frontmatterEnd + 3) : ["---", ...(0, frontmatter_1.formatFrontmatter)(parsed.frontmatter, frontmatter_1.DEFAULT_FRONTMATTER_KEY_ORDER), "---"].join("\n");
362
+ const nextBody = appendMissingHeadings(parsed.body, addedHeadings);
363
+ const nextContent = `${frontmatterBlock}\n${nextBody.endsWith("\n") ? nextBody : `${nextBody}\n`}`;
364
+ changes.push({
365
+ filePath,
366
+ path: path_1.default.relative(options.root, filePath).split(path_1.default.sep).join("/"),
367
+ id: typeof parsed.frontmatter.id === "string" ? parsed.frontmatter.id : undefined,
368
+ type,
369
+ added_headings: addedHeadings,
370
+ content: nextContent,
371
+ });
372
+ }
373
+ }
374
+ if (errors.length > 0) {
375
+ for (const error of errors) {
376
+ console.error(error);
377
+ }
378
+ throw new errors_1.ValidationError(`format --headings failed with ${errors.length} error(s)`);
379
+ }
380
+ const apply = options.apply === true;
381
+ if (apply) {
382
+ for (const change of changes) {
383
+ (0, atomic_1.atomicWriteFile)(change.filePath, change.content);
384
+ }
385
+ }
386
+ const publicChanges = changes.map(({ filePath: _filePath, content: _content, ...change }) => change);
387
+ const summaryLimit = options.summary ? normalizeLimit(options.limit) : undefined;
388
+ const emittedChanges = options.summary ? publicChanges.slice(0, summaryLimit) : publicChanges;
389
+ const receipt = {
390
+ action: "format.headings",
391
+ ok: true,
392
+ dry_run: !apply,
393
+ applied: apply,
394
+ changed_count: changes.length,
395
+ summary: buildHeadingsSummary(publicChanges, summaryLimit),
396
+ changes: emittedChanges,
397
+ };
398
+ if (options.json) {
399
+ console.log(JSON.stringify(receipt, null, 2));
400
+ return;
401
+ }
402
+ console.log(`${apply ? "format headings updated" : "format headings dry-run"} ${changes.length} file(s)`);
403
+ }
253
404
  function runFormatCommandLocked(options) {
405
+ if (options.headings) {
406
+ runHeadingFormatCommandLocked(options);
407
+ return;
408
+ }
254
409
  const config = (0, config_1.loadConfig)(options.root);
255
410
  const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(options.root, config, node_1.ALLOWED_TYPES);
256
411
  const filesByAlias = (0, workspace_files_1.listWorkspaceDocFilesByAlias)(options.root, config);
@@ -227,6 +227,7 @@ function goalReceipt(root, loaded) {
227
227
  goal_condition: String(fm.goal_condition ?? ""),
228
228
  scope_refs: toStringList(fm.scope_refs),
229
229
  active_node: optionalString(fm.active_node),
230
+ last_active_node: optionalString(fm.last_active_node),
230
231
  required_skills: toStringList(fm.required_skills),
231
232
  required_checks: toStringList(fm.required_checks),
232
233
  max_iterations: optionalString(fm.max_iterations),
@@ -276,6 +277,11 @@ function isConcreteCandidate(node, statusRanks) {
276
277
  }
277
278
  return node.status !== "done";
278
279
  }
280
+ function isAchievedGoal(node, frontmatter) {
281
+ const status = frontmatter ? String(frontmatter.status ?? "") : node.status;
282
+ const goalState = frontmatter ? String(frontmatter.goal_state ?? "") : String(node.attributes.goal_state ?? "");
283
+ return status === "done" || goalState === "achieved";
284
+ }
279
285
  function resolveCandidate(index, idOrQid, ws) {
280
286
  const resolved = (0, qid_1.resolveQid)(index, idOrQid, ws);
281
287
  if (resolved.status !== "ok") {
@@ -283,6 +289,29 @@ function resolveCandidate(index, idOrQid, ws) {
283
289
  }
284
290
  return index.nodes[resolved.qid];
285
291
  }
292
+ function readOnlySubgraphBlockerWarnings(index, qids) {
293
+ const warnings = [];
294
+ const seen = new Set();
295
+ for (const qid of [...qids].sort()) {
296
+ const node = index.nodes[qid];
297
+ if (!node || node.source?.imported || node.status === "done") {
298
+ continue;
299
+ }
300
+ for (const blockerQid of node.edges.blocked_by) {
301
+ const blocker = index.nodes[blockerQid];
302
+ if (!blocker?.source?.imported) {
303
+ continue;
304
+ }
305
+ const subgraph = blocker.source.subgraph_alias;
306
+ const warning = `${node.qid} is blocked by read-only subgraph node ${blocker.qid}; update the source workspace for subgraph ${subgraph} or refresh the subgraph bundle before claiming local work`;
307
+ if (!seen.has(warning)) {
308
+ seen.add(warning);
309
+ warnings.push(warning);
310
+ }
311
+ }
312
+ }
313
+ return warnings;
314
+ }
286
315
  function runGoalShowCommand(options) {
287
316
  const loaded = loadGoal(options.root, options.id, options.ws);
288
317
  const receipt = { action: "showed", goal: goalReceipt(options.root, loaded) };
@@ -296,6 +325,9 @@ function runGoalShowCommand(options) {
296
325
  if (loaded.frontmatter.active_node) {
297
326
  console.log(`active_node: ${loaded.frontmatter.active_node}`);
298
327
  }
328
+ if (loaded.frontmatter.last_active_node) {
329
+ console.log(`last_active_node: ${loaded.frontmatter.last_active_node}`);
330
+ }
299
331
  const checks = toStringList(loaded.frontmatter.required_checks);
300
332
  console.log(`required_checks: ${checks.length === 0 ? "none" : checks.join(", ")}`);
301
333
  }
@@ -351,6 +383,23 @@ function runGoalNextCommand(options) {
351
383
  console.error("no actionable local work found for goal");
352
384
  return;
353
385
  }
386
+ if (isAchievedGoal(loaded.node, loaded.frontmatter)) {
387
+ if (options.json) {
388
+ console.log(JSON.stringify({
389
+ action: "selected",
390
+ goal: goalReceipt(options.root, loaded),
391
+ goal_source: loaded.resolutionSource,
392
+ node: null,
393
+ warnings: loaded.warnings,
394
+ }, null, 2));
395
+ return;
396
+ }
397
+ for (const warning of loaded.warnings) {
398
+ console.error(`warning: ${warning}`);
399
+ }
400
+ console.error("no actionable local work found for achieved goal");
401
+ return;
402
+ }
354
403
  const statusPreference = loaded.config.work.next.status_preference.map((status) => status.toLowerCase());
355
404
  const statusRanks = new Set(statusPreference);
356
405
  const warnings = [...loaded.warnings];
@@ -362,6 +411,7 @@ function runGoalNextCommand(options) {
362
411
  for (const invalid of scope.invalidRefs) {
363
412
  warnings.push(`scope contains non-actionable or unsupported node: ${invalid}`);
364
413
  }
414
+ warnings.push(...readOnlySubgraphBlockerWarnings(loaded.index, scope.actionableQids));
365
415
  if (activeNode) {
366
416
  const node = resolveCandidate(loaded.index, activeNode, loaded.node.ws);
367
417
  if (node && scope.actionableQids.has(node.qid) && isConcreteCandidate(node, statusRanks)) {
@@ -549,6 +599,7 @@ function runGoalCurrentCommand(options) {
549
599
  goal_condition: String(node.attributes.goal_condition ?? ""),
550
600
  scope_refs: toStringList(node.attributes.scope_refs),
551
601
  active_node: optionalString(node.attributes.active_node),
602
+ last_active_node: optionalString(node.attributes.last_active_node),
552
603
  required_skills: toStringList(node.attributes.required_skills),
553
604
  required_checks: toStringList(node.attributes.required_checks),
554
605
  max_iterations: optionalString(node.attributes.max_iterations),
@@ -643,6 +694,15 @@ function runGoalStateMutationLocked(action, options) {
643
694
  throw new errors_1.UsageError(`cannot ${action} archived goal ${loaded.node.qid}`);
644
695
  }
645
696
  const now = options.now ?? new Date();
697
+ if (action === "done" || action === "archive") {
698
+ const activeNode = optionalString(loaded.frontmatter.active_node);
699
+ if (activeNode) {
700
+ if (!loaded.frontmatter.last_active_node) {
701
+ loaded.frontmatter.last_active_node = activeNode;
702
+ }
703
+ delete loaded.frontmatter.active_node;
704
+ }
705
+ }
646
706
  loaded.frontmatter.goal_state = GOAL_STATE_BY_ACTION[action];
647
707
  loaded.frontmatter.status = ensureStatusAllowed(loaded.config, STATUS_BY_ACTION[action]);
648
708
  writeGoalFile(loaded, now);