godpowers 2.4.1 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.2] - 2026-06-09
11
+
12
+ ### Added
13
+ - Added strict YAML diagnostics for the dependency-free parser, covering
14
+ skipped malformed lines, unsafe prototype-pollution keys, and legacy empty
15
+ array shorthand.
16
+ - Added shared markdown frontmatter parsing through `lib/frontmatter.js` and a
17
+ static check that blocks new inline parser drift.
18
+ - Added dev-only coverage tooling through `c8` and `npm run coverage`.
19
+
20
+ ### Changed
21
+ - Routing, recipe, and workflow loaders now surface strict YAML warnings with
22
+ file and line context.
23
+ - Installer metadata, Pillars, skill validation, agent validation, checkpoint
24
+ reads, context budgets, skill surface metadata, and DESIGN.md parsing now use
25
+ the shared frontmatter helper.
26
+
27
+ ### Fixed
28
+ - Extension manifests now fail closed on malformed YAML lines and unsafe keys
29
+ instead of accepting partially parsed manifests.
30
+ - Removed stale root tarballs and `.DS_Store` package clutter before release.
31
+
10
32
  ## [2.4.1] - 2026-06-08
11
33
 
12
34
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/aihxp/godpowers/actions/workflows/ci.yml/badge.svg)](https://github.com/aihxp/godpowers/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
- [![Version](https://img.shields.io/badge/version-2.4.1-blue)](CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/version-2.4.2-blue)](CHANGELOG.md)
6
6
  [![npm](https://img.shields.io/npm/v/godpowers.svg)](https://www.npmjs.com/package/godpowers)
7
7
 
8
8
  **Ship fast. Ship right. Ship everything. Ship accountably.**
@@ -22,10 +22,10 @@ Godpowers makes AI coding accountable: every serious run should leave disk
22
22
  state, artifacts, validation gates, host guarantees, and a next action. Code is
23
23
  only one output. The project memory and proof trail matter too.
24
24
 
25
- Version 2.4.1 keeps the 2.4 command-family UX and adds a clearer first trust
26
- step: Quick Proof outcome metrics, a First 10 Minute Proof case study,
27
- profile-first onboarding, and surface-discipline guidance for future command
28
- growth.
25
+ Version 2.4.2 keeps the 2.4 command-family UX and hardens the release-facing
26
+ runtime: strict YAML diagnostics for routing, recipes, workflows, and extension
27
+ manifests; shared markdown frontmatter parsing; dev-only coverage tooling; and
28
+ clean package hygiene before publish.
29
29
 
30
30
  Maintainer hardening continues on the 2.x line with small, audited public
31
31
  surface updates when they close real workflow gaps. The 2.1.0 patch closes a command-injection vector in the
package/RELEASE.md CHANGED
@@ -1,11 +1,12 @@
1
- # Godpowers 2.4.1 Release
1
+ # Godpowers 2.4.2 Release
2
2
 
3
3
  > Status: Ready for package verification
4
- > Date: 2026-06-08
4
+ > Date: 2026-06-09
5
5
 
6
- Godpowers 2.4.1 is an adoption-proof patch for the 2.4 line. It keeps the
7
- 2.4.0 command-family UX intact while making the first trust step smaller,
8
- measurable, and easier to inspect before a user commits to a full project arc.
6
+ Godpowers 2.4.2 is a release-hardening patch for the 2.4 line. It keeps the
7
+ 2.4 command-family UX intact while making parser failures, frontmatter
8
+ metadata, coverage visibility, and package hygiene more accountable before
9
+ publish.
9
10
 
10
11
  ## What's in this release
11
12
 
@@ -16,38 +17,42 @@ measurable, and easier to inspect before a user commits to a full project arc.
16
17
 
17
18
  ## Highlights
18
19
 
19
- - Quick Proof now reports outcome metrics: commands to first signal,
20
- disk-state source, tracked steps, missing planning artifacts, next command,
21
- host level, and host gaps.
22
- - Adoption Canary reports now include CLI-verifiable outcome metrics for
23
- quick-proof, status, and next signals.
24
- - README and Getting Started now lead with `--profile=core` and the brief Quick
25
- Proof path before full autonomy.
26
- - The First 10 Minute Proof case study documents the local before-and-after
27
- proof while clearly naming what still requires an external repository canary.
28
- - Reference and Roadmap docs now include surface-discipline guidance: try
29
- families, ladders, profiles, recipes, typed route outcomes, and docs before
30
- adding new public commands.
31
- - Package guardrails now require `lib/adoption-metrics.js`.
20
+ - YAML parsing now exposes strict diagnostics for malformed skipped lines and
21
+ unsafe prototype-pollution keys while preserving default legacy reads.
22
+ - Routing, recipe, and workflow YAML loaders now surface warnings with file and
23
+ line context.
24
+ - Extension manifests now fail closed on malformed YAML lines and unsafe keys.
25
+ - Markdown frontmatter parsing is centralized in `lib/frontmatter.js` and
26
+ enforced by `scripts/static-check.js`.
27
+ - Installer metadata, Pillars, skill validation, agent validation,
28
+ checkpoints, context budgets, skill surface metadata, and DESIGN.md parsing
29
+ now share the same frontmatter path.
30
+ - `npm run coverage` is available through dev-only `c8` tooling.
31
+ - Stale root package tarballs and `.DS_Store` clutter were removed before
32
+ release.
32
33
 
33
34
  ## Validation
34
35
 
35
- - `npm test` green across the full suite
36
- - `npm run test:audit` green
37
- - `npm run pack:check` green
38
- - `npm pack` creates a local `godpowers-2.4.1.tgz` tarball for package
36
+ - `node scripts/test-yaml-parser.js` green
37
+ - `node scripts/test-frontmatter.js` green
38
+ - `node scripts/test-extensions.js` green
39
+ - `node scripts/test-router.js` green
40
+ - `node scripts/test-recipes.js` green
41
+ - `node scripts/test-install-smoke.js` green
42
+ - `npm run release:check` required before publish
43
+ - `npm pack` creates a local `godpowers-2.4.2.tgz` tarball for package
39
44
  inspection
40
45
 
41
46
  ## Upgrade
42
47
 
43
- - `npm install -g godpowers@2.4.1` or `npx godpowers@2.4.1`
48
+ - `npm install -g godpowers@2.4.2` or `npx godpowers@2.4.2`
44
49
  - Re-run `/god-context` in each project to refresh installed runtime metadata
45
- - No breaking changes; existing `.godpowers/` state is compatible. Users who
46
- want a compact install can run `npx godpowers --profile=core`.
50
+ - No breaking changes for valid `.godpowers/` state. Invalid extension
51
+ manifests that were previously partially accepted now fail with parse errors.
47
52
 
48
53
  ## Notes
49
54
 
50
- - GitHub release creation for `v2.4.1`
55
+ - GitHub release creation for `v2.4.2`
51
56
  - The tag should match the npm package version
52
- - The `v2.4.1` tag should point to the release commit that matches the npm
53
- `godpowers@2.4.1` package.
57
+ - The `v2.4.2` tag should point to the release commit that matches the npm
58
+ `godpowers@2.4.2` package.
package/lib/README.md CHANGED
@@ -11,6 +11,7 @@ package-level integrations.
11
11
  | `state.js` | Read, initialize, validate, and write `.godpowers/state.json`. |
12
12
  | `state-lock.js` | Coordinate state writes with a lock file. |
13
13
  | `intent.js` | Read and validate `intent.yaml` from project roots or `.godpowers/`. |
14
+ | `frontmatter.js` | Parse shared markdown YAML frontmatter for skills, agents, Pillars, checkpoints, and design specs. |
14
15
  | `checkpoint.js` | Create and inspect resumable checkpoint artifacts. |
15
16
  | `feature-awareness.js` | Detect and refresh existing-project awareness after runtime upgrades. |
16
17
  | `code-intelligence.js` | Detect optional `ast-grep`, `sg`, and LSP tooling for structural search, rewrite, and diagnostics guidance. |
@@ -18,6 +18,7 @@
18
18
 
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
+ const frontmatter = require('./frontmatter');
21
22
 
22
23
  const REQUIRED_FRONTMATTER = ['name', 'description'];
23
24
  const RECOMMENDED_FRONTMATTER = ['tools'];
@@ -37,33 +38,7 @@ function parseAgentFile(filePath) {
37
38
  raw
38
39
  };
39
40
 
40
- // Frontmatter block: --- ... ---
41
- if (raw.startsWith('---')) {
42
- const end = raw.indexOf('\n---', 3);
43
- if (end > 0) {
44
- const fmBlock = raw.slice(3, end).trim();
45
- // Parse simple YAML (key: value, key: |, etc.)
46
- let currentMultiline = null;
47
- for (const line of fmBlock.split('\n')) {
48
- if (currentMultiline) {
49
- if (line.startsWith(' ') || line.startsWith('\t')) {
50
- result.frontmatter[currentMultiline] += '\n' + line.trim();
51
- continue;
52
- }
53
- currentMultiline = null;
54
- }
55
- const m = line.match(/^([\w-]+):\s*(.*)$/);
56
- if (m) {
57
- if (m[2] === '|' || m[2] === '>') {
58
- currentMultiline = m[1];
59
- result.frontmatter[m[1]] = '';
60
- } else {
61
- result.frontmatter[m[1]] = m[2].trim();
62
- }
63
- }
64
- }
65
- }
66
- }
41
+ result.frontmatter = frontmatter.parse(raw, { strict: true, source: filePath });
67
42
 
68
43
  // Sections: lines starting with # / ## / ###
69
44
  const lines = raw.split('\n');
package/lib/checkpoint.js CHANGED
@@ -50,6 +50,7 @@ const fs = require('fs');
50
50
  const path = require('path');
51
51
  const crypto = require('crypto');
52
52
  const atomic = require('./atomic-write');
53
+ const frontmatterLib = require('./frontmatter');
53
54
 
54
55
  const MAX_ACTIONS = 20;
55
56
  const MAX_FACTS = 10;
@@ -74,25 +75,9 @@ function read(projectRoot) {
74
75
  if (!fs.existsSync(file)) return null;
75
76
  const raw = fs.readFileSync(file, 'utf8');
76
77
 
77
- // Frontmatter parse
78
- let frontmatter = {};
79
- let body = raw;
80
- if (raw.startsWith('---')) {
81
- const end = raw.indexOf('\n---', 3);
82
- if (end > 0) {
83
- const fmText = raw.slice(3, end).trim();
84
- body = raw.slice(end + 4).trim();
85
- for (const line of fmText.split('\n')) {
86
- const m = line.match(/^([\w-]+):\s*(.*)$/);
87
- if (m) {
88
- let v = m[2].trim();
89
- if (v === 'true') v = true;
90
- else if (v === 'false') v = false;
91
- frontmatter[m[1]] = v;
92
- }
93
- }
94
- }
95
- }
78
+ const parsed = frontmatterLib.split(raw, { strict: true, source: file });
79
+ const frontmatter = parsed.frontmatter || {};
80
+ const body = parsed.body.trim();
96
81
 
97
82
  // Section parse for actions and facts
98
83
  const actions = parseList(body, 'Last actions');
@@ -25,6 +25,7 @@
25
25
 
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
+ const frontmatter = require('./frontmatter');
28
29
 
29
30
  const BYTES_PER_TOKEN = 4; // rough English text heuristic
30
31
  const DEFAULT_MAX_TOKENS = 80_000; // per-agent default; ~half of a 200K window
@@ -52,44 +53,14 @@ function parseAgentBudget(agentPath) {
52
53
  return { required: [], optional: [], maxTokens: null };
53
54
  }
54
55
  const raw = fs.readFileSync(agentPath, 'utf8');
55
- let fmText = '';
56
- if (raw.startsWith('---')) {
57
- const end = raw.indexOf('\n---', 3);
58
- if (end > 0) fmText = raw.slice(3, end);
59
- }
60
-
56
+ const metadata = frontmatter.parse(raw, { strict: true, source: agentPath });
61
57
  const out = { required: [], optional: [], maxTokens: null };
62
- // very loose YAML-ish parse, just for our subset
63
- const lines = fmText.split('\n');
64
- for (let i = 0; i < lines.length; i++) {
65
- const line = lines[i].trim();
66
- if (line.startsWith('required-context:')) {
67
- out.required = parseListInline(line.slice('required-context:'.length), lines, i);
68
- } else if (line.startsWith('optional-context:')) {
69
- out.optional = parseListInline(line.slice('optional-context:'.length), lines, i);
70
- } else if (line.startsWith('max-tokens:')) {
71
- const n = parseInt(line.slice('max-tokens:'.length).trim(), 10);
72
- if (Number.isFinite(n)) out.maxTokens = n;
73
- }
74
- }
58
+ if (Array.isArray(metadata['required-context'])) out.required = metadata['required-context'];
59
+ if (Array.isArray(metadata['optional-context'])) out.optional = metadata['optional-context'];
60
+ if (Number.isFinite(metadata['max-tokens'])) out.maxTokens = metadata['max-tokens'];
75
61
  return out;
76
62
  }
77
63
 
78
- function parseListInline(rest, lines, idx) {
79
- const r = rest.trim();
80
- if (r.startsWith('[') && r.endsWith(']')) {
81
- return r.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
82
- }
83
- // Block list: subsequent lines starting with '- '
84
- const items = [];
85
- for (let j = idx + 1; j < lines.length; j++) {
86
- const l = lines[j];
87
- if (!l.startsWith(' - ')) break;
88
- items.push(l.slice(4).trim().replace(/^["']|["']$/g, ''));
89
- }
90
- return items;
91
- }
92
-
93
64
  /**
94
65
  * Compute total bytes + estimated tokens for a list of file paths.
95
66
  * Missing files contribute 0; not an error.
@@ -15,7 +15,7 @@
15
15
  * lint(content) -> { findings, valid } // run all checks
16
16
  */
17
17
 
18
- const intent = require('./intent');
18
+ const frontmatter = require('./frontmatter');
19
19
 
20
20
  const VALID_SECTIONS = [
21
21
  'Overview', 'Brand & Style',
@@ -48,23 +48,21 @@ const COMPONENT_PROPS = [
48
48
  * Parse a DESIGN.md file: separate YAML frontmatter from markdown body.
49
49
  */
50
50
  function parse(content) {
51
- const errors = [];
52
51
  if (!content.startsWith('---')) {
53
52
  return { frontmatter: null, body: content, errors: ['Missing YAML frontmatter (file must start with `---`).'] };
54
53
  }
55
- const end = content.indexOf('\n---', 3);
56
- if (end === -1) {
54
+ const parsed = frontmatter.split(content, { strict: true, source: 'DESIGN.md' });
55
+ if (!parsed.frontmatter) {
57
56
  return { frontmatter: null, body: content, errors: ['Frontmatter not closed (missing closing `---`).'] };
58
57
  }
59
- const yamlBlock = content.slice(3, end).trim();
60
- const body = content.slice(end + 4).trim();
61
- let frontmatter = null;
62
- try {
63
- frontmatter = intent.parseSimpleYaml(yamlBlock);
64
- } catch (e) {
65
- errors.push(`Frontmatter YAML parse error: ${e.message}`);
66
- }
67
- return { frontmatter, body, errors };
58
+ const errors = parsed.diagnostics.map((diagnostic) =>
59
+ `Frontmatter YAML warning: ${diagnostic.message} on line ${diagnostic.line}`
60
+ );
61
+ return {
62
+ frontmatter: errors.length > 0 ? null : parsed.frontmatter,
63
+ body: parsed.body.trim(),
64
+ errors
65
+ };
68
66
  }
69
67
 
70
68
  /**
package/lib/extensions.js CHANGED
@@ -41,8 +41,18 @@ function parseManifest(yamlText) {
41
41
  return { manifest: null, errors: ['empty manifest'] };
42
42
  }
43
43
  try {
44
- const manifest = intentLib.parseSimpleYaml(yamlText);
45
- return { manifest, errors: [] };
44
+ const parsed = intentLib.parseSimpleYamlWithDiagnostics(yamlText, {
45
+ strict: true,
46
+ source: 'manifest.yaml',
47
+ unsafeKeySeverity: 'error'
48
+ });
49
+ if (parsed.diagnostics.length > 0) {
50
+ return {
51
+ manifest: null,
52
+ errors: parsed.diagnostics.map(intentLib.formatDiagnostic)
53
+ };
54
+ }
55
+ return { manifest: parsed.data, errors: [] };
46
56
  } catch (e) {
47
57
  return { manifest: null, errors: ['parse error: ' + e.message] };
48
58
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared YAML frontmatter helpers for markdown-backed Godpowers contracts.
3
+ *
4
+ * Frontmatter uses the same dependency-free YAML subset as intent, routing,
5
+ * recipes, workflows, and extension manifests.
6
+ */
7
+
8
+ const { parseSimpleYamlWithDiagnostics } = require('./intent');
9
+
10
+ function hasOpeningFence(text) {
11
+ return typeof text === 'string' && text.startsWith('---');
12
+ }
13
+
14
+ function split(text, opts = {}) {
15
+ const source = opts.source || null;
16
+ if (!hasOpeningFence(text)) {
17
+ return {
18
+ frontmatter: null,
19
+ body: text,
20
+ rawFrontmatter: '',
21
+ diagnostics: opts.require ? [{
22
+ severity: 'warning',
23
+ line: 1,
24
+ source,
25
+ message: 'Missing YAML frontmatter fence'
26
+ }] : []
27
+ };
28
+ }
29
+
30
+ const end = text.indexOf('\n---', 3);
31
+ if (end === -1) {
32
+ return {
33
+ frontmatter: null,
34
+ body: text,
35
+ rawFrontmatter: '',
36
+ diagnostics: [{
37
+ severity: 'warning',
38
+ line: 1,
39
+ source,
40
+ message: 'YAML frontmatter fence is not closed'
41
+ }]
42
+ };
43
+ }
44
+
45
+ const rawFrontmatter = text.slice(3, end).trim();
46
+ const parsed = parseSimpleYamlWithDiagnostics(rawFrontmatter, {
47
+ strict: opts.strict === true,
48
+ source,
49
+ unsafeKeySeverity: opts.unsafeKeySeverity || 'warning',
50
+ onDiagnostic: opts.onDiagnostic
51
+ });
52
+
53
+ return {
54
+ frontmatter: parsed.data,
55
+ body: text.slice(end + 4).trimStart(),
56
+ rawFrontmatter,
57
+ diagnostics: parsed.diagnostics
58
+ };
59
+ }
60
+
61
+ function parse(text, opts = {}) {
62
+ const result = split(text, opts);
63
+ return result.frontmatter || {};
64
+ }
65
+
66
+ function strip(text) {
67
+ return split(text).body.trim();
68
+ }
69
+
70
+ module.exports = {
71
+ split,
72
+ parse,
73
+ strip
74
+ };
@@ -5,6 +5,7 @@ const { ensureDir, copyRecursive, copyRuntimeBundle } = require('./installer-fil
5
5
  const { resolveRuntime } = require('./installer-runtimes');
6
6
  const { selectedSkillNames, normalizeProfiles } = require('./install-profiles');
7
7
  const identity = require('./package-identity');
8
+ const frontmatter = require('./frontmatter');
8
9
 
9
10
  const VERSION = identity.PACKAGE_VERSION;
10
11
 
@@ -46,52 +47,6 @@ function installSkillFile(srcFile, skillsDest, runtimeKey, targetName = null) {
46
47
  fs.copyFileSync(srcFile, path.join(skillsDest, `${baseName}.md`));
47
48
  }
48
49
 
49
- function parseAgentFrontmatter(content) {
50
- const fallback = { name: null, description: null };
51
- if (!content.startsWith('---\n')) return fallback;
52
-
53
- const end = content.indexOf('\n---', 4);
54
- if (end === -1) return fallback;
55
-
56
- const lines = content.slice(4, end).split('\n');
57
- const parsed = { ...fallback };
58
-
59
- for (let i = 0; i < lines.length; i++) {
60
- const line = lines[i];
61
- const nameMatch = line.match(/^name:\s*(.+)\s*$/);
62
- if (nameMatch) {
63
- parsed.name = nameMatch[1].replace(/^["']|["']$/g, '');
64
- continue;
65
- }
66
-
67
- if (line === 'description: |') {
68
- const desc = [];
69
- i++;
70
- while (i < lines.length && /^ {2}/.test(lines[i])) {
71
- desc.push(lines[i].slice(2));
72
- i++;
73
- }
74
- i--;
75
- parsed.description = desc.join('\n').trim();
76
- continue;
77
- }
78
-
79
- const descMatch = line.match(/^description:\s*(.+)\s*$/);
80
- if (descMatch) {
81
- parsed.description = descMatch[1].replace(/^["']|["']$/g, '');
82
- }
83
- }
84
-
85
- return parsed;
86
- }
87
-
88
- function stripFrontmatter(content) {
89
- if (!content.startsWith('---\n')) return content.trim();
90
- const end = content.indexOf('\n---', 4);
91
- if (end === -1) return content.trim();
92
- return content.slice(end + 4).trim();
93
- }
94
-
95
50
  function tomlString(value) {
96
51
  return JSON.stringify(value || '');
97
52
  }
@@ -102,10 +57,10 @@ function tomlLiteral(value) {
102
57
 
103
58
  function writeCodexAgentToml(srcFile, agentsDest) {
104
59
  const content = fs.readFileSync(srcFile, 'utf8');
105
- const frontmatter = parseAgentFrontmatter(content);
106
- const name = frontmatter.name || path.basename(srcFile, '.md');
107
- const description = frontmatter.description || `Godpowers specialist agent: ${name}.`;
108
- const instructions = stripFrontmatter(content);
60
+ const metadata = frontmatter.parse(content, { strict: true, source: srcFile });
61
+ const name = metadata.name || path.basename(srcFile, '.md');
62
+ const description = metadata.description || `Godpowers specialist agent: ${name}.`;
63
+ const instructions = frontmatter.strip(content);
109
64
  const toml = [
110
65
  `name = ${tomlString(name)}`,
111
66
  `description = ${tomlString(description)}`,
@@ -371,8 +326,8 @@ module.exports = {
371
326
  uninstallForRuntime,
372
327
  countInstalledSurface: countProfileSurface,
373
328
  installSkillFile,
374
- parseAgentFrontmatter,
375
- stripFrontmatter,
329
+ parseAgentFrontmatter: frontmatter.parse,
330
+ stripFrontmatter: frontmatter.strip,
376
331
  writeCodexAgentToml,
377
332
  removeSkillEntry,
378
333
  pruneGodpowersSkills,
package/lib/intent.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Note: this is a minimal YAML reader, intentionally avoiding a YAML
7
7
  * dependency. Handles the subset of YAML our intent files use.
8
+ * Strict callers can collect diagnostics for skipped lines and unsafe keys.
8
9
  * For complex YAML, agents read the file directly.
9
10
  */
10
11
 
@@ -66,15 +67,59 @@ function isUnsafeKey(key) {
66
67
  * Parse a simple YAML subset. Just enough for intent.yaml structure.
67
68
  * Real-world: replace with `yaml` npm package when we add deps.
68
69
  */
69
- function parseSimpleYaml(content) {
70
+ function createDiagnostic(severity, line, message, source) {
71
+ return {
72
+ severity,
73
+ line,
74
+ source: source || null,
75
+ message
76
+ };
77
+ }
78
+
79
+ function formatDiagnostic(diagnostic) {
80
+ const source = diagnostic.source ? `${diagnostic.source}:` : '';
81
+ return `${source}${diagnostic.line}: ${diagnostic.message}`;
82
+ }
83
+
84
+ function diagnosticsError(diagnostics, label = 'YAML diagnostics') {
85
+ const error = new Error(`${label}:\n - ${diagnostics.map(formatDiagnostic).join('\n - ')}`);
86
+ error.diagnostics = diagnostics;
87
+ return error;
88
+ }
89
+
90
+ function parseSimpleYaml(content, opts = {}) {
91
+ const result = parseSimpleYamlWithDiagnostics(content, opts);
92
+ if (opts.throwOnDiagnostics && result.diagnostics.length > 0) {
93
+ throw diagnosticsError(result.diagnostics, opts.errorLabel);
94
+ }
95
+ return result.data;
96
+ }
97
+
98
+ function parseSimpleYamlWithDiagnostics(content, opts = {}) {
70
99
  const lines = content.split('\n');
71
100
  const result = {};
72
101
  const stack = [{ obj: result, indent: -1, key: null, isArray: false, parent: null }];
102
+ const diagnostics = [];
103
+ const strict = opts.strict === true;
104
+ const source = opts.source || null;
105
+ const unsafeKeySeverity = opts.unsafeKeySeverity || 'warning';
106
+
107
+ function record(severity, lineNumber, message) {
108
+ const diagnostic = createDiagnostic(severity, lineNumber, message, source);
109
+ diagnostics.push(diagnostic);
110
+ if (typeof opts.onDiagnostic === 'function') opts.onDiagnostic(diagnostic);
111
+ }
112
+
113
+ function recordSkipped(lineNumber, rawLine, reason) {
114
+ if (!strict) return;
115
+ record('warning', lineNumber, `${reason}: ${rawLine.trim()}`);
116
+ }
73
117
 
74
118
  for (let i = 0; i < lines.length; i++) {
75
119
  let line = lines[i];
76
120
  if (!line.trim() || line.trim().startsWith('#')) continue;
77
121
  line = stripInlineComment(line);
122
+ if (!line.trim()) continue;
78
123
 
79
124
  const indent = line.length - line.trimStart().length;
80
125
  const trimmed = line.trim();
@@ -84,6 +129,16 @@ function parseSimpleYaml(content) {
84
129
  stack.pop();
85
130
  }
86
131
  const parent = stack[stack.length - 1].obj;
132
+
133
+ if (trimmed === '[]') {
134
+ const current = stack[stack.length - 1];
135
+ if (current.parent && current.key && Object.keys(current.obj).length === 0) {
136
+ current.parent[current.key] = [];
137
+ stack.pop();
138
+ continue;
139
+ }
140
+ }
141
+
87
142
  // List item: "- key: value" or "- value"
88
143
  if (trimmed.startsWith('- ')) {
89
144
  const rest = trimmed.slice(2);
@@ -102,7 +157,14 @@ function parseSimpleYaml(content) {
102
157
  } else {
103
158
  // List of objects: "- key: value"
104
159
  const itemKey = rest.slice(0, restColonIdx).trim();
105
- if (isUnsafeKey(itemKey)) continue;
160
+ if (!itemKey) {
161
+ recordSkipped(i + 1, lines[i], 'Missing list item key');
162
+ continue;
163
+ }
164
+ if (isUnsafeKey(itemKey)) {
165
+ record(unsafeKeySeverity, i + 1, `Unsafe YAML key rejected: ${itemKey}`);
166
+ continue;
167
+ }
106
168
  const itemVal = rest.slice(restColonIdx + 1).trim();
107
169
  const newObj = {};
108
170
  if (itemVal) {
@@ -122,9 +184,19 @@ function parseSimpleYaml(content) {
122
184
  }
123
185
 
124
186
  const colonIdx = findUnquotedColon(trimmed);
125
- if (colonIdx === -1) continue;
187
+ if (colonIdx === -1) {
188
+ recordSkipped(i + 1, lines[i], 'Unparseable YAML line skipped');
189
+ continue;
190
+ }
126
191
  const key = trimmed.slice(0, colonIdx).trim();
127
- if (isUnsafeKey(key)) continue;
192
+ if (!key) {
193
+ recordSkipped(i + 1, lines[i], 'Missing YAML key');
194
+ continue;
195
+ }
196
+ if (isUnsafeKey(key)) {
197
+ record(unsafeKeySeverity, i + 1, `Unsafe YAML key rejected: ${key}`);
198
+ continue;
199
+ }
128
200
  const valueStr = trimmed.slice(colonIdx + 1).trim();
129
201
 
130
202
  if (!valueStr) {
@@ -140,7 +212,7 @@ function parseSimpleYaml(content) {
140
212
  }
141
213
  }
142
214
 
143
- return cleanArrays(result);
215
+ return { data: cleanArrays(result), diagnostics };
144
216
  }
145
217
 
146
218
  function readBlockScalar(lines, startIndex, parentIndent, folded) {
@@ -301,4 +373,14 @@ function validate(intent) {
301
373
  return errors;
302
374
  }
303
375
 
304
- module.exports = { read, readAsync, get, validate, intentPath, parseSimpleYaml };
376
+ module.exports = {
377
+ read,
378
+ readAsync,
379
+ get,
380
+ validate,
381
+ intentPath,
382
+ parseSimpleYaml,
383
+ parseSimpleYamlWithDiagnostics,
384
+ diagnosticsError,
385
+ formatDiagnostic
386
+ };
package/lib/pillars.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
+ const frontmatterLib = require('./frontmatter');
11
12
 
12
13
  const PILLARS_FENCE_BEGIN = '<!-- pillars:begin -->';
13
14
  const PILLARS_FENCE_END = '<!-- pillars:end -->';
@@ -119,47 +120,7 @@ function stripQuotes(value) {
119
120
  return String(value).trim().replace(/^['"]|['"]$/g, '');
120
121
  }
121
122
 
122
- function parseInlineList(value) {
123
- const trimmed = value.trim();
124
- if (trimmed === '[]') return [];
125
- if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
126
- const body = trimmed.slice(1, -1).trim();
127
- if (!body) return [];
128
- return body.split(',').map(part => stripQuotes(part)).filter(Boolean);
129
- }
130
-
131
- function parseScalar(value) {
132
- const trimmed = value.trim();
133
- if (trimmed === 'true') return true;
134
- if (trimmed === 'false') return false;
135
- const list = parseInlineList(trimmed);
136
- if (list) return list;
137
- return stripQuotes(trimmed);
138
- }
139
-
140
- function parseFrontmatter(raw) {
141
- if (!raw.startsWith('---\n')) return null;
142
- const end = raw.indexOf('\n---', 4);
143
- if (end === -1) return null;
144
-
145
- const frontmatter = {};
146
- const lines = raw.slice(4, end).split('\n');
147
- let currentKey = null;
148
- for (const line of lines) {
149
- const match = line.match(/^([\w-]+):\s*(.*)$/);
150
- if (match) {
151
- currentKey = match[1];
152
- frontmatter[currentKey] = parseScalar(match[2]);
153
- continue;
154
- }
155
- const itemMatch = line.match(/^\s*-\s*(.+)$/);
156
- if (currentKey && itemMatch) {
157
- if (!Array.isArray(frontmatter[currentKey])) frontmatter[currentKey] = [];
158
- frontmatter[currentKey].push(stripQuotes(itemMatch[1]));
159
- }
160
- }
161
- return frontmatter;
162
- }
123
+ const parseFrontmatter = (raw) => frontmatterLib.parse(raw, { strict: true });
163
124
 
164
125
  function walkMarkdown(dir) {
165
126
  if (!fs.existsSync(dir)) return [];
package/lib/recipes.js CHANGED
@@ -8,13 +8,17 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { parseSimpleYaml } = require('./intent');
11
+ const { parseSimpleYaml, formatDiagnostic } = require('./intent');
12
12
  const state = require('./state');
13
13
 
14
14
  const RECIPES_DIR = path.join(__dirname, '..', 'routing', 'recipes');
15
15
 
16
16
  let _cache = null;
17
17
 
18
+ function warnYamlDiagnostic(diagnostic) {
19
+ console.warn(`[godpowers] YAML warning ${formatDiagnostic(diagnostic)}`);
20
+ }
21
+
18
22
  /**
19
23
  * Load all recipe definitions.
20
24
  */
@@ -26,7 +30,11 @@ function loadAll() {
26
30
  for (const file of fs.readdirSync(RECIPES_DIR)) {
27
31
  if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue;
28
32
  const content = fs.readFileSync(path.join(RECIPES_DIR, file), 'utf8');
29
- const parsed = parseSimpleYaml(content);
33
+ const parsed = parseSimpleYaml(content, {
34
+ strict: true,
35
+ source: path.join('routing', 'recipes', file),
36
+ onDiagnostic: warnYamlDiagnostic
37
+ });
30
38
  if (parsed.metadata && parsed.metadata.name) {
31
39
  result.push(parsed);
32
40
  }
package/lib/router.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
- const { parseSimpleYaml } = require('./intent');
13
+ const { parseSimpleYaml, formatDiagnostic } = require('./intent');
14
14
  const state = require('./state');
15
15
  const commandFamilies = require('./command-families');
16
16
 
@@ -24,6 +24,10 @@ const HARDEN_FINDINGS = '.godpowers/harden/FINDINGS.md';
24
24
 
25
25
  let _cache = null;
26
26
 
27
+ function warnYamlDiagnostic(diagnostic) {
28
+ console.warn(`[godpowers] YAML warning ${formatDiagnostic(diagnostic)}`);
29
+ }
30
+
27
31
  /**
28
32
  * Load all routing files into a map keyed by command.
29
33
  */
@@ -35,7 +39,11 @@ function loadAll() {
35
39
  for (const file of fs.readdirSync(ROUTING_DIR)) {
36
40
  if (!file.endsWith('.yaml')) continue;
37
41
  const content = fs.readFileSync(path.join(ROUTING_DIR, file), 'utf8');
38
- const parsed = parseSimpleYaml(content);
42
+ const parsed = parseSimpleYaml(content, {
43
+ strict: true,
44
+ source: path.join('routing', file),
45
+ onDiagnostic: warnYamlDiagnostic
46
+ });
39
47
  if (parsed.metadata && parsed.metadata.command) {
40
48
  result[parsed.metadata.command] = parsed;
41
49
  }
@@ -1,17 +1,8 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const frontmatter = require('./frontmatter');
3
4
 
4
- function parseFrontmatter(text) {
5
- if (!text.startsWith('---\n')) return {};
6
- const end = text.indexOf('\n---', 4);
7
- if (end === -1) return {};
8
- const out = {};
9
- for (const line of text.slice(4, end).split('\n')) {
10
- const match = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
11
- if (match) out[match[1]] = match[2].replace(/^["']|["']$/g, '');
12
- }
13
- return out;
14
- }
5
+ const parseFrontmatter = (text) => frontmatter.parse(text, { strict: true });
15
6
 
16
7
  function listSkills(rootDir = path.join(__dirname, '..', 'skills')) {
17
8
  return fs.readdirSync(rootDir)
@@ -7,7 +7,11 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { parseSimpleYaml } = require('./intent');
10
+ const { parseSimpleYaml, formatDiagnostic } = require('./intent');
11
+
12
+ function warnYamlDiagnostic(diagnostic) {
13
+ console.warn(`[godpowers] YAML warning ${formatDiagnostic(diagnostic)}`);
14
+ }
11
15
 
12
16
  /**
13
17
  * Parse a workflow YAML file into a structured object.
@@ -17,14 +21,18 @@ function parseFile(filePath) {
17
21
  throw new Error(`Workflow not found: ${filePath}`);
18
22
  }
19
23
  const content = fs.readFileSync(filePath, 'utf8');
20
- return parse(content);
24
+ return parse(content, filePath);
21
25
  }
22
26
 
23
27
  /**
24
28
  * Parse YAML content into a workflow object.
25
29
  */
26
- function parse(yamlContent) {
27
- const parsed = parseSimpleYaml(yamlContent);
30
+ function parse(yamlContent, source = 'workflow.yaml') {
31
+ const parsed = parseSimpleYaml(yamlContent, {
32
+ strict: true,
33
+ source,
34
+ onDiagnostic: warnYamlDiagnostic
35
+ });
28
36
  validate(parsed);
29
37
  return parsed;
30
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "godpowers",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "description": "AI-powered development system: 112 slash commands and 40 specialist agents that take a project from raw idea to hardened production. Runs inside Claude Code, Codex, Cursor, Windsurf, Gemini, and 10+ other AI coding tools.",
5
5
  "bin": {
6
6
  "godpowers": "./bin/install.js"
@@ -22,6 +22,7 @@
22
22
  "test:linter": "node scripts/test-artifact-linter.js",
23
23
  "test:diff": "node scripts/test-artifact-diff.js",
24
24
  "test:e2e": "node tests/integration/full-arc.test.js",
25
+ "coverage": "c8 --reporter=text --reporter=lcov node scripts/run-tests.js",
25
26
  "test:audit": "npm audit --omit=dev && git diff --check && npm run test:surface",
26
27
  "pack:check": "node scripts/check-package-contents.js",
27
28
  "release:check": "npm test && npm run test:audit && npm run pack:check",
@@ -86,5 +87,8 @@
86
87
  "AGENTS.md",
87
88
  "CHANGELOG.md",
88
89
  "LICENSE"
89
- ]
90
+ ],
91
+ "devDependencies": {
92
+ "c8": "^11.0.0"
93
+ }
90
94
  }