mindforge-cc 11.9.1 → 11.9.3

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 (121) hide show
  1. package/.agent/CLAUDE.md +37 -13
  2. package/.agent/hooks/mindforge-block-no-verify.js +61 -13
  3. package/.agent/hooks/mindforge-config-protection.js +82 -3
  4. package/.agent/hooks/mindforge-context-monitor.js +1 -1
  5. package/.agent/hooks/mindforge-workflow-guard.js +2 -2
  6. package/.agent/hooks/run-with-flags.js +190 -20
  7. package/.agent/mindforge/browse.md +2 -2
  8. package/.agent/mindforge/checkpoint.md +1 -1
  9. package/.agent/mindforge/consult.md +1 -1
  10. package/.agent/mindforge/cost-report.md +1 -1
  11. package/.agent/mindforge/harness-audit.md +1 -1
  12. package/.agent/mindforge/orch-add-feature.md +1 -1
  13. package/.agent/mindforge/orch-build-mvp.md +1 -1
  14. package/.agent/mindforge/orch-change-feature.md +1 -1
  15. package/.agent/mindforge/orch-fix-defect.md +1 -1
  16. package/.agent/mindforge/orch-refine-code.md +1 -1
  17. package/.agent/mindforge/qa.md +2 -2
  18. package/.claude/CLAUDE.md +37 -13
  19. package/.claude/commands/mindforge/browse.md +2 -2
  20. package/.claude/commands/mindforge/checkpoint.md +1 -1
  21. package/.claude/commands/mindforge/consult.md +1 -1
  22. package/.claude/commands/mindforge/cost-report.md +1 -1
  23. package/.claude/commands/mindforge/harness-audit.md +1 -1
  24. package/.claude/commands/mindforge/orch-add-feature.md +1 -1
  25. package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
  26. package/.claude/commands/mindforge/orch-change-feature.md +1 -1
  27. package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
  28. package/.claude/commands/mindforge/orch-refine-code.md +1 -1
  29. package/.claude/commands/mindforge/qa.md +2 -2
  30. package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
  31. package/.mindforge/config.json +4 -4
  32. package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
  33. package/.mindforge/engine/cost-tracking/router.md +1 -1
  34. package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
  35. package/.mindforge/engine/temporal-protocol.md +2 -2
  36. package/.mindforge/governance/change-classifier.md +20 -4
  37. package/.mindforge/memory/sync-manifest.json +1 -1
  38. package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
  39. package/.mindforge/personas/cost-optimizer.md +2 -2
  40. package/.mindforge/personas/multi-model-bridge.md +1 -1
  41. package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
  42. package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
  43. package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
  44. package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
  45. package/CHANGELOG.md +402 -0
  46. package/MINDFORGE.md +13 -6
  47. package/README.md +51 -2
  48. package/RELEASENOTES.md +55 -2
  49. package/SECURITY.md +22 -3
  50. package/bin/autonomous/audit-writer.js +48 -33
  51. package/bin/autonomous/auto-runner.js +65 -2
  52. package/bin/change-classifier.js +151 -16
  53. package/bin/dashboard/api-router.js +28 -47
  54. package/bin/dashboard/error-response.js +44 -0
  55. package/bin/dashboard/frontend/app.js +429 -0
  56. package/bin/dashboard/frontend/index.html +14 -390
  57. package/bin/dashboard/metrics-aggregator.js +75 -30
  58. package/bin/dashboard/revops-api.js +12 -2
  59. package/bin/dashboard/server.js +245 -6
  60. package/bin/dashboard/sse-bridge.js +11 -8
  61. package/bin/dashboard/temporal-api.js +11 -5
  62. package/bin/engine/remediation-engine.js +12 -1
  63. package/bin/engine/sre-manager.js +1 -1
  64. package/bin/engine/temporal-cli.js +56 -6
  65. package/bin/engine/temporal-hub.js +41 -9
  66. package/bin/engine/verification-runner.js +134 -17
  67. package/bin/engine/verify-cli.js +25 -7
  68. package/bin/eval/eval-harness.js +212 -1
  69. package/bin/eval/golden-set-retrieval.json +9 -0
  70. package/bin/governance/approval-record.js +147 -0
  71. package/bin/governance/approve.js +12 -7
  72. package/bin/governance/policy-engine.js +41 -3
  73. package/bin/governance/policy-gate-hardened.js +36 -1
  74. package/bin/governance/verify-approvals.js +163 -0
  75. package/bin/harness-audit.js +224 -10
  76. package/bin/hindsight-injector.js +8 -2
  77. package/bin/hooks/instinct-capture-hook.js +19 -5
  78. package/bin/install.js +63 -3
  79. package/bin/installer/harness-adapter-compliance.js +339 -28
  80. package/bin/installer/hook-registration.js +504 -0
  81. package/bin/installer-core.js +451 -63
  82. package/bin/learning/instinct-cli.js +14 -24
  83. package/bin/memory/knowledge-capture.js +23 -3
  84. package/bin/memory/knowledge-graph.js +70 -31
  85. package/bin/memory/vector-hub.js +500 -44
  86. package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
  87. package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
  88. package/bin/mindforge-cli.js +110 -17
  89. package/bin/models/cost-tracker.js +126 -29
  90. package/bin/models/model-client.js +6 -1
  91. package/bin/models/model-router.js +28 -7
  92. package/bin/models/usage-record.js +71 -0
  93. package/bin/revops/debt-monitor.js +57 -13
  94. package/bin/security/trust-gate-hook.js +50 -6
  95. package/bin/skill-validator.js +6 -1
  96. package/bin/skills-builder/skill-scorer.js +46 -6
  97. package/bin/updater/self-update.js +6 -1
  98. package/bin/updater/version-comparator.js +21 -1
  99. package/bin/utils/file-lock.js +106 -0
  100. package/bin/utils/mindforge-params.js +124 -0
  101. package/bin/utils/mindforge-version.js +99 -0
  102. package/bin/utils/redact-secrets.js +106 -0
  103. package/bin/validate-config.js +75 -17
  104. package/bin/wizard/setup-wizard.js +4 -1
  105. package/bin/wizard/theme.js +9 -1
  106. package/changelogs/index.json +11 -9
  107. package/changelogs/v11.9.2.md +209 -0
  108. package/changelogs/v11.9.3.md +195 -0
  109. package/docs/References/config-reference.md +76 -14
  110. package/docs/References/sdk-api.md +1 -1
  111. package/docs/Templates/Codebase/architecture.md +1 -1
  112. package/docs/commands-reference.md +4 -5
  113. package/docs/faq.md +25 -5
  114. package/docs/getting-started.md +3 -3
  115. package/docs/sdk-reference.md +15 -7
  116. package/docs/troubleshooting.md +10 -6
  117. package/docs/user-guide.md +14 -14
  118. package/examples/sdk-integration/README.md +1 -1
  119. package/package.json +10 -4
  120. package/subagents/.claude-plugin/marketplace.json +1 -1
  121. package/bin/dashboard/approval-handler.js +0 -136
@@ -0,0 +1,71 @@
1
+ /**
2
+ * MindForge — Token-usage ledger record shape.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for `.mindforge/metrics/token-usage.jsonl`.
5
+ * The writer (bin/models/cost-tracker.js, fed by every provider in
6
+ * bin/models/*-provider.js) and every reader (bin/models/cost-tracker.js,
7
+ * bin/dashboard/metrics-aggregator.js) MUST go through this module so the
8
+ * field names cannot drift apart again.
9
+ *
10
+ * Canonical row (all five providers emit exactly this cost field):
11
+ * {
12
+ * model, input_tokens, output_tokens,
13
+ * cache_read_input_tokens?, cache_creation_input_tokens?,
14
+ * cost_usd, // <- the ONLY cost field. Never total_cost_usd.
15
+ * task_name?, session_id?, phase?,
16
+ * date, // 'YYYY-MM-DD', added by buildRecord()
17
+ * timestamp // full ISO 8601, added by buildRecord()
18
+ * }
19
+ *
20
+ * `total_cost_usd` is a DIFFERENT concept that belongs to cross-review reports
21
+ * (bin/review/cross-review-engine.js:72) and must never appear in this ledger.
22
+ *
23
+ * NOTE ON PATHS: resolution stays on process.cwd() (not bin/utils/paths.js
24
+ * findProjectRoot) to preserve today's behaviour exactly; relocating user state
25
+ * is deliberately deferred to v12.
26
+ *
27
+ * KNOWN STALE SITE, deliberately NOT migrated here: bin/migrations/1.0.0-to-2.0.0.js:93
28
+ * resolves the ledger to .planning/token-usage.jsonl — a path that has never existed —
29
+ * so that migration silently no-ops. Repointing it is out of scope for COST-01 because
30
+ * it would start a migration that has never run against real data. Until then, this
31
+ * module is the single source of truth for every LIVE reader and writer, not literally
32
+ * every path-resolution site in the tree.
33
+ */
34
+ 'use strict';
35
+
36
+ const path = require('path');
37
+
38
+ // Project-root-relative location of the append-only usage ledger.
39
+ const LEDGER_SEGMENTS = ['.mindforge', 'metrics', 'token-usage.jsonl'];
40
+
41
+ /** Absolute path to the ledger. Resolved lazily so tests can chdir. */
42
+ function ledgerPath(root = process.cwd()) {
43
+ return path.join(root, ...LEDGER_SEGMENTS);
44
+ }
45
+
46
+ /** Directory containing the ledger. */
47
+ function ledgerDir(root = process.cwd()) {
48
+ return path.join(root, ...LEDGER_SEGMENTS.slice(0, -1));
49
+ }
50
+
51
+ /** Stamp a provider result into a canonical ledger row. */
52
+ function buildRecord(entry, now = new Date()) {
53
+ const iso = now.toISOString();
54
+ return { ...entry, date: iso.slice(0, 10), timestamp: iso };
55
+ }
56
+
57
+ /** Cost of one row, in USD. Returns 0 for absent/non-finite values. */
58
+ function entryCost(entry) {
59
+ const v = entry && entry.cost_usd;
60
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
61
+ }
62
+
63
+ /** 'YYYY-MM-DD' day of one row: prefers `date`, falls back to `timestamp`. */
64
+ function entryDay(entry) {
65
+ if (!entry) return '';
66
+ if (typeof entry.date === 'string' && entry.date.length >= 10) return entry.date.slice(0, 10);
67
+ if (typeof entry.timestamp === 'string' && entry.timestamp.length >= 10) return entry.timestamp.slice(0, 10);
68
+ return '';
69
+ }
70
+
71
+ module.exports = { LEDGER_SEGMENTS, ledgerPath, ledgerDir, buildRecord, entryCost, entryDay };
@@ -4,27 +4,69 @@
4
4
  */
5
5
  'use strict';
6
6
 
7
- const fs = require('fs');
8
- const path = require('path');
7
+ // A `require('fs')` and a `this.auditPath = path.join(...)` used to sit here. Both were dead —
8
+ // `grep 'fs\.'` over this file returns nothing and no caller reads `auditPath` — and their presence
9
+ // implied this module reads the audit log, which it does not. Its only input is the array handed to
10
+ // monitor() by bin/dashboard/revops-api.js. Removed so the module's actual surface is legible.
9
11
 
10
- class DebtMonitor {
11
- constructor() {
12
- this.auditPath = path.join(process.cwd(), '.planning', 'AUDIT.jsonl');
13
- }
12
+ /**
13
+ * The score is computed from exactly these three event types. If an audit log contains none of them,
14
+ * this module measured NOTHING — which is a different state from measuring cleanly, and the whole
15
+ * point of the sentinel below.
16
+ */
17
+ const TRACKED_EVENTS = ['security_finding', 'approval_granted', 'policy_bypass'];
18
+
19
+ /**
20
+ * Reported in place of a score, a status and a debt level when nothing was measured.
21
+ *
22
+ * WHY A STRING and not null. Both dashboard frontends call `.toUpperCase()` on `governance_status`
23
+ * and `debt_level` (index.html:735,747 and, in PR #186's extracted copy, app.js:395,407 — verified
24
+ * byte-identical), so null or a number is a hard TypeError there, not a blank tile.
25
+ * `security_health_score` is assigned with a bare `textContent =` (index.html:732), so a string
26
+ * passes straight through and renders as the word. Confirmed by loading the real render function into
27
+ * a headless browser: the tile reads "Unmeasured", the badge "STATUS: UNMEASURED", and the `> 80`
28
+ * colour comparison is false, so the badge goes red rather than green. No frontend edit needed.
29
+ */
30
+ const UNMEASURED = 'Unmeasured';
14
31
 
32
+ class DebtMonitor {
15
33
  /**
16
34
  * Monitor governance debt and security health.
17
35
  * @param {Object} metrics - From MetricsAggregator
18
36
  */
19
37
  monitor(metrics) {
20
38
  const auditEntries = metrics.auditEntries || [];
21
-
22
- // 1. Identify high-risk events
23
- const criticalFindings = auditEntries.filter(e => e.event === 'security_finding' && e.severity === 'critical');
24
- const tier3Approvals = auditEntries.filter(e => e.event === 'approval_granted' && e.tier === 3);
25
- const policyBypasses = auditEntries.filter(e => e.event === 'policy_bypass');
26
39
 
27
- // 2. Calculate Health Score (starts at 100)
40
+ // 1. Identify high-risk events.
41
+ // Severity is compared case-INSENSITIVELY. It used to require lowercase 'critical', while
42
+ // .mindforge/audit/AUDIT-SCHEMA.md:269 declares severity uppercase ("HIGH"). Measured: three
43
+ // schema-compliant `severity: 'CRITICAL'` findings scored 100/"Excellent"/"Minimal", while the
44
+ // same three lowercase scored 70/"Warning"/"Moderate" — so a compliant writer was invisible.
45
+ const criticalFindings = auditEntries.filter((e) => e.event === 'security_finding'
46
+ && String(e.severity || '').toLowerCase() === 'critical');
47
+ const tier3Approvals = auditEntries.filter((e) => e.event === 'approval_granted' && e.tier === 3);
48
+ const policyBypasses = auditEntries.filter((e) => e.event === 'policy_bypass');
49
+
50
+ // 2. Refuse to report health that was never measured.
51
+ // Previously an absent audit log, an empty one, and a clean one were indistinguishable: all
52
+ // three returned 100 / "Excellent" / "Minimal". Measured, the absent and empty cases were
53
+ // byte-identical but for the timestamp. Reporting a maximum for the absence of evidence is
54
+ // the inverse of what a security score is for.
55
+ const measuredEvents = auditEntries.filter((e) => TRACKED_EVENTS.includes(e.event)).length;
56
+ if (measuredEvents === 0) {
57
+ return {
58
+ security_health_score: UNMEASURED,
59
+ governance_status: UNMEASURED,
60
+ critical_findings: 0,
61
+ tier3_approvals: 0,
62
+ policy_bypasses: 0,
63
+ debt_level: UNMEASURED,
64
+ measured: false,
65
+ timestamp: new Date().toISOString(),
66
+ };
67
+ }
68
+
69
+ // 3. Calculate Health Score (starts at 100)
28
70
  let score = 100;
29
71
  score -= (criticalFindings.length * 10);
30
72
  score -= (tier3Approvals.length * 5);
@@ -32,7 +74,7 @@ class DebtMonitor {
32
74
 
33
75
  const healthScore = Math.max(0, score);
34
76
 
35
- // 3. Determine status
77
+ // 4. Determine status
36
78
  let status = 'Excellent';
37
79
  if (healthScore < 90) status = 'Good';
38
80
  if (healthScore < 75) status = 'Warning';
@@ -45,6 +87,7 @@ class DebtMonitor {
45
87
  tier3_approvals: tier3Approvals.length,
46
88
  policy_bypasses: policyBypasses.length,
47
89
  debt_level: this.getDebtLevel(healthScore),
90
+ measured: true,
48
91
  timestamp: new Date().toISOString()
49
92
  };
50
93
  }
@@ -58,3 +101,4 @@ class DebtMonitor {
58
101
  }
59
102
 
60
103
  module.exports = new DebtMonitor();
104
+ module.exports.UNMEASURED = UNMEASURED;
@@ -3,6 +3,53 @@
3
3
 
4
4
  const { isHighImpact } = require('./trust-boundaries');
5
5
 
6
+ /**
7
+ * WHY THERE IS NO TOOL-NAME CHECK HERE.
8
+ *
9
+ * THE DEFECT. This hook used to open with `if (event.tool_name !== 'Bash') process.exit(0)`, so a
10
+ * deny-class gate permitted every call whose tool was not spelled with that exact string. Measured
11
+ * against the real hook with one destructive command and only the tool name varied:
12
+ *
13
+ * tool_name=Bash -> DENY (exit 2)
14
+ * tool_name=Shell -> PERMIT (exit 0)
15
+ * tool_name=shell -> PERMIT (exit 0)
16
+ * tool_name=PowerShell -> PERMIT (exit 0)
17
+ * tool_name=run_terminal_cmd -> PERMIT (exit 0)
18
+ * tool_name=Terminal -> PERMIT (exit 0)
19
+ *
20
+ * That is reachable today, not hypothetical. Verified inside cursor-agent 2026.04.17's own bundle:
21
+ * it loads the exact file the installer writes —
22
+ *
23
+ * claudeProjectConfigPath: join(e, ".claude", "settings.json")
24
+ * claudeUserConfigPath: join(homedir(), ".claude", "settings.json")
25
+ *
26
+ * — and translates Claude matcher names to its own tool names through
27
+ * `{Bash:"Shell", Read:"Read", Write:"Write", Edit:"Write", Grep:"Grep", ...}`, while its hook
28
+ * normaliser defaults to `{loop_limit:null, failClosed:!1}`. So opening a MindForge-installed project
29
+ * in Cursor silently drops this gate, and its shell payload carries the SAME
30
+ * `{command, workingDirectory, timeout}` shape — meaning nothing but the name check stood between a
31
+ * destructive command and execution, while the install receipt reported three deny-class hooks
32
+ * verified blocking.
33
+ *
34
+ * THE SCOPE IS `tool_input.command`, AND THAT IS SUFFICIENT. No name list replaces the check, because
35
+ * a list only ever covers the harnesses someone thought to enumerate and the next one that spells its
36
+ * tool differently reopens the hole in silence. Every shell tool observed — Claude Code's Bash and
37
+ * Cursor's Shell — passes the command as `tool_input.command`, so reading that field IS the scoping:
38
+ * a Write, Edit, Read or Grep payload has no `.command`, yields the empty string, and is permitted
39
+ * without a name ever being consulted. An unknown harness using the conventional shape now fails
40
+ * CLOSED instead of open.
41
+ *
42
+ * A FIRST ATTEMPT AT THIS FIX ADDED A `looksLikeShellCall()` GUARD, and falsification proved it did
43
+ * nothing. Deleting that guard outright left all 14 tests green, because the extraction below already
44
+ * scopes the gate. Its comment claimed removal would over-block Write payloads whose CONTENT mentions
45
+ * a destructive command — measurably false, since such payloads carry no `.command`. Keeping it would
46
+ * have shipped machinery that looks like enforcement and performs none, which is the exact defect
47
+ * class this gate was being repaired for. So it is gone, and this comment stands in its place.
48
+ *
49
+ * MCP calls are unaffected: Cursor passes `tool_input: JSON.stringify(d)` for those, so `.command` on
50
+ * a string is undefined and the gate declines rather than guessing.
51
+ */
52
+
6
53
  let input = '';
7
54
  process.stdin.setEncoding('utf8');
8
55
  process.stdin.on('data', (chunk) => { input += chunk; });
@@ -10,12 +57,9 @@ process.stdin.on('end', () => {
10
57
  try {
11
58
  const event = JSON.parse(input);
12
59
 
13
- // Only gate Bash tool calls
14
- if (event.tool_name !== 'Bash') {
15
- process.exit(0); // allow
16
- }
17
-
18
- const fullCommand = event.tool_input?.command || '';
60
+ // Scoped by payload shape, NOT by tool name — see the header for the measured fail-open this
61
+ // replaces. A non-executing call has no `.command`, so it yields '' and is permitted below.
62
+ const fullCommand = (typeof event.tool_input?.command === 'string' ? event.tool_input.command : '');
19
63
 
20
64
  // Check the whole command AND every individual line, blocking if ANY
21
65
  // segment is high-impact. Per-line scanning means a benign first line
@@ -22,7 +22,12 @@ const colors = {
22
22
  };
23
23
 
24
24
  function main() {
25
- const target = ARGS[0];
25
+ // The first NON-FLAG argument. This was `ARGS[0]`, so any flag placed before the path was taken
26
+ // as the filename: `validate-skill --no-color skill/SKILL.md` reported
27
+ // "File not found: <cwd>/--no-color" and exited 1. Flags-before-operands is the ordering most
28
+ // people reach for, and the two flags this tool documents (--no-color, --enterprise) are both
29
+ // read from anywhere in ARGS, so only the target lookup was position-sensitive.
30
+ const target = ARGS.find((a) => !a.startsWith('-'));
26
31
  if (!target || ARGS.includes('--help') || ARGS.includes('-h')) {
27
32
  console.log('\nUsage: mindforge-cc validate-skill <path-to-SKILL.md>\n');
28
33
  process.exit(0);
@@ -44,17 +44,57 @@ const GENERIC_TRIGGERS = new Set([
44
44
  'response', 'handler', 'controller', 'repository', 'schema',
45
45
  ]);
46
46
 
47
+ // ── Trigger frontmatter, in both grammars ─────────────────────────────────────
48
+ //
49
+ // THE DEFECT THIS FIXES. This parser required a YAML BLOCK list:
50
+ //
51
+ // const triggersSection = frontmatter.match(/^triggers:\n((?: {2}- .+\n?)*)/m);
52
+ //
53
+ // The shipped corpus is written in the INLINE comma form. Measured by driving this module's real
54
+ // parseSkill over the real .mindforge/skills tree: 232 skills, 232 with ZERO triggers, mean 0.00 —
55
+ // 232 inline, 0 block. So every engine skill scored 0 of the 30 points `score()` awards for trigger
56
+ // coverage (:95-99), and the suggestion at :233 told skills carrying 12 triggers to "add 25 more to
57
+ // reach 25+ (currently 0)". Three callers — learn-cli.js, skill-registrar.js, skill-generator.js —
58
+ // make registration decisions against that uniformly-zero scoreboard.
59
+ //
60
+ // bin/skill-validator.js:119 has always read the inline form (`fm.triggers.split(',')`). Two readers
61
+ // of the same field, two incompatible grammars, and the corpus written in the one the scorer could
62
+ // not read. The DIVERGENCE is the real defect; the regex is just where it surfaced.
63
+ //
64
+ // BOTH grammars are supported deliberately. The block form is not legacy cruft: all three fixtures
65
+ // in tests/self-building-skills.test.js use it, and :209 asserts >= 24 triggers on one of them, so
66
+ // dropping it would silently zero those fixtures and the test would still pass for the wrong reason.
67
+ //
68
+ // DELIBERATELY NOT DONE HERE: extracting one shared frontmatter reader so the scorer and the
69
+ // validator cannot diverge again. That is the durable fix, but it edits bin/skill-validator.js,
70
+ // which PR #185 is currently changing. Doing it here would collide. Follow-up once #185 lands.
71
+ function parseTriggers(frontmatter) {
72
+ // Block form first. The ordering is defence in depth, not the load-bearing part — verified rather
73
+ // than assumed: the `\S` in the inline pattern below already makes it REJECT a block-form header,
74
+ // because after `triggers:` the next character is a newline and `.` does not match one. Loosen
75
+ // that `\S` to `.` and the inline branch starts matching block form with an EMPTY capture, so a
76
+ // list of triggers would silently parse as none. Two independent things prevent that; keep both.
77
+ // `[ \t]+` rather than the old `{2}` — two spaces is a convention, not a requirement.
78
+ const block = frontmatter.match(/^triggers:[ \t]*\n((?:[ \t]+-[ \t]+.+\n?)+)/m);
79
+ if (block) {
80
+ return block[1].split('\n').map((l) => l.replace(/^\s*-\s*/, '').trim()).filter(Boolean);
81
+ }
82
+
83
+ // Inline comma form, e.g. `triggers: a11y testing, axe-core, WCAG compliance test`.
84
+ const inline = frontmatter.match(/^triggers:[ \t]*(\S.*)$/m);
85
+ if (inline) {
86
+ return inline[1].split(',').map((t) => t.trim()).filter(Boolean);
87
+ }
88
+
89
+ return [];
90
+ }
91
+
47
92
  // ── SKILL.md parser ───────────────────────────────────────────────────────────
48
93
  function parseSkill(content) {
49
94
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
50
95
  const frontmatter = frontmatterMatch?.[1] || '';
51
96
 
52
- // Extract triggers from frontmatter
53
- const triggersSection = frontmatter.match(/^triggers:\n((?: {2}- .+\n?)*)/m);
54
- const triggers = (triggersSection?.[1] || '')
55
- .split('\n')
56
- .map(l => l.replace(/^\s*- /, '').trim())
57
- .filter(Boolean);
97
+ const triggers = parseTriggers(frontmatter);
58
98
 
59
99
  // Count code blocks with ≥ 3 lines (meaningful examples, not one-liners)
60
100
  const codeBlockMatches = content.match(/```[\s\S]*?```/g) || [];
@@ -10,7 +10,12 @@ const { execSync } = require('child_process');
10
10
  const { compareSemver, upgradeType, fetchLatestVersion } = require('./version-comparator');
11
11
  const { fetchChangelog } = require('./changelog-fetcher');
12
12
 
13
- const CURRENT_VERSION = require('../../package.json').version;
13
+ // MindForge's OWN version, not the host project's. `require('../../package.json')` resolved to
14
+ // the CONSUMER's manifest once bin/updater/ was copied into a project — measured 1.0.0 for an
15
+ // app installed alongside MindForge 11.9.2, which made every upgrade read as MAJOR and fed a
16
+ // bogus "from" version into the migration path below.
17
+ const { resolveMindforgeVersion } = require('../utils/mindforge-version');
18
+ const CURRENT_VERSION = resolveMindforgeVersion({ fromDir: __dirname }).version;
14
19
 
15
20
  /**
16
21
  * Detect where MindForge was originally installed.
@@ -37,6 +37,26 @@ function upgradeType(current, latest) {
37
37
  * Returns null on any error — callers must handle gracefully.
38
38
  * Timeout: 5 seconds (respects enterprise proxies that may be slow).
39
39
  */
40
+ /**
41
+ * MindForge's own version for the User-Agent, or 'unknown'.
42
+ *
43
+ * The header used to interpolate `require('../../package.json').version`, which in an INSTALL is
44
+ * the consumer's manifest — measured 1.0.0 for a host app sitting alongside MindForge 11.9.2, so
45
+ * the registry saw the wrong client version. Resolved by package NAME instead.
46
+ *
47
+ * Required inline, and failure degrades to 'unknown' rather than throwing: this file documents
48
+ * itself as pure with no external dependencies, and an unresolvable version is no reason to break
49
+ * an update check. self-update.js throws in the same situation, correctly — there a wrong version
50
+ * would misclassify the upgrade.
51
+ */
52
+ function userAgentVersion() {
53
+ try {
54
+ return require('../utils/mindforge-version').resolveMindforgeVersion({ fromDir: __dirname }).version;
55
+ } catch {
56
+ return 'unknown';
57
+ }
58
+ }
59
+
40
60
  async function fetchLatestVersion(packageName = 'mindforge-cc') {
41
61
  const https = require('https');
42
62
  return new Promise(resolve => {
@@ -44,7 +64,7 @@ async function fetchLatestVersion(packageName = 'mindforge-cc') {
44
64
  hostname: 'registry.npmjs.org',
45
65
  path: `/${encodeURIComponent(packageName)}/latest`,
46
66
  method: 'GET',
47
- headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${require('../../package.json').version}` },
67
+ headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${userAgentVersion()}` },
48
68
  timeout: 5000,
49
69
  };
50
70
 
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MindForge — Fail-closed advisory file lock (LOCK-01).
5
+ *
6
+ * Promoted from bin/learning/instinct-cli.js:78-100 (`withStoreLock`), preserving its
7
+ * semantics: an O_CREAT|O_EXCL lockfile beside the target, a bounded retry spin, a
8
+ * stale-reclaim by mtime for locks orphaned by a killed process, and unlink in finally.
9
+ *
10
+ * FAIL-CLOSED: when the lock cannot be taken this THROWS. It NEVER writes anyway.
11
+ * Deliberately NOT modelled on .agent/bin/lib/state.cjs:784-789, which unlinks the other
12
+ * holder's lock and writes regardless on its last retry — that converts a detectable
13
+ * contention error into silent data loss.
14
+ *
15
+ * `fn` MUST be SYNCHRONOUS. The re-entrancy depth counter below is only sound because
16
+ * nothing else in this process can interleave between acquire and release.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const MAX_TRIES = 50; // 50 x 20-40ms => ~1-2s ceiling before failing closed
23
+ const WAIT_MS = 20;
24
+ const STALE_MS = 10000; // reclaim a lockfile whose mtime is older than this
25
+
26
+ // Re-entrancy depth per lockfile. Required: knowledge-graph applyDecay() holds this
27
+ // lock and calls deprecateEdge(), which takes the SAME lock. Without re-entrancy that
28
+ // self-deadlocks and then fails closed, so a decay pass could never prune an edge.
29
+ const _held = new Map(); // lockPath -> depth
30
+
31
+ const _sleepView = new Int32Array(new SharedArrayBuffer(4));
32
+
33
+ /** Blocking sleep that does not burn a core; busy-waits if Atomics.wait is refused. */
34
+ function sleepSync(ms) {
35
+ try {
36
+ Atomics.wait(_sleepView, 0, 0, ms);
37
+ } catch {
38
+ const until = Date.now() + ms;
39
+ while (Date.now() < until) { /* busy-wait fallback */ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Rejects an async fn. The `finally` in withFileLock unlinks the lockfile the moment
45
+ * fn() returns, so a promise-returning fn would run its critical section UNLOCKED while
46
+ * looking guarded. Failing loudly here is far cheaper than debugging that corruption.
47
+ */
48
+ function assertSync(out) {
49
+ if (out && typeof out.then === 'function') {
50
+ throw new TypeError('withFileLock requires a SYNCHRONOUS fn; got a thenable');
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Runs fn() while holding an exclusive advisory lock on `${targetPath}.lock`.
57
+ * @param {string} targetPath the file being protected (the lock is a sibling `.lock`)
58
+ * @param {Function} fn SYNCHRONOUS critical section; its return value is returned
59
+ * @param {object} [opts] { maxTries, waitMs, staleMs, label }
60
+ * @returns {*} whatever fn() returns
61
+ * @throws if the lock cannot be acquired within maxTries — the caller MUST NOT write
62
+ */
63
+ function withFileLock(targetPath, fn, opts = {}) {
64
+ const lock = `${targetPath}.lock`;
65
+
66
+ const depth = _held.get(lock) || 0;
67
+ if (depth > 0) { // already ours — re-enter, do not re-acquire
68
+ _held.set(lock, depth + 1);
69
+ try { return assertSync(fn()); }
70
+ finally { _held.set(lock, _held.get(lock) - 1); }
71
+ }
72
+
73
+ const maxTries = opts.maxTries ?? MAX_TRIES;
74
+ const waitMs = opts.waitMs ?? WAIT_MS;
75
+ const staleMs = opts.staleMs ?? STALE_MS;
76
+ const label = opts.label || targetPath;
77
+
78
+ // The lock must be creatable before the target's own mkdir runs, so ensure the dir
79
+ // here. A bad path still throws ENOTDIR/ENOENT to the caller, unchanged.
80
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
81
+
82
+ let acquired = false;
83
+ for (let i = 0; i < maxTries && !acquired; i++) {
84
+ try {
85
+ fs.closeSync(fs.openSync(lock, 'wx')); // O_CREAT|O_EXCL|O_WRONLY
86
+ acquired = true;
87
+ } catch (err) {
88
+ if (err.code !== 'EEXIST') throw err;
89
+ try {
90
+ const age = Date.now() - fs.statSync(lock).mtimeMs;
91
+ if (age > staleMs) { fs.unlinkSync(lock); continue; } // orphaned by a kill
92
+ } catch { /* lock vanished between EEXIST and stat — retry */ }
93
+ sleepSync(waitMs + Math.floor(Math.random() * waitMs)); // jitter breaks the herd
94
+ }
95
+ }
96
+ if (!acquired) throw new Error(`could not acquire ${label} lock: ${lock}`);
97
+
98
+ _held.set(lock, 1);
99
+ try { return assertSync(fn()); }
100
+ finally {
101
+ _held.set(lock, 0);
102
+ try { fs.unlinkSync(lock); } catch { /* already gone */ }
103
+ }
104
+ }
105
+
106
+ module.exports = { withFileLock, MAX_TRIES, WAIT_MS, STALE_MS };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /**
3
+ * MindForge — MINDFORGE.md parameter parser (single source of truth).
4
+ *
5
+ * TWO on-disk forms are supported, because both ship:
6
+ *
7
+ * 1. BRACKETED (current, MINDFORGE.md) — 43 keys in the shipped registry:
8
+ * [PLANNER] = claude-opus-4-7
9
+ * [MODE] = "Platform Sovereign"
10
+ * [API_URL] = <http://localhost:3000>
11
+ * [PQAS_ENFORCED] = false # trailing note
12
+ * [FORBIDDEN] = """
13
+ * ...multi-line block...
14
+ * """
15
+ *
16
+ * 2. LEGACY PLAIN (shell-style) — 28 keys in examples/starter-project/MINDFORGE.md,
17
+ * which ships (package.json files[] contains "examples/"), and the
18
+ * tests/cli-router.test.js:110 fixture. A bracket-only parser silently
19
+ * zeroes those out:
20
+ * MAX_TASKS_PER_PHASE=999
21
+ * DISABLED_SKILLS= <- empty value is LEGAL, hence (.*) not (.+)
22
+ *
23
+ * Bracketed semantics are ported from sdk/src/client.ts:141-158 (the only
24
+ * previously correct bracket-aware reader) and extended with value
25
+ * normalisation so the runtime and the validator agree on one interpretation.
26
+ *
27
+ * NOT captured: prose bullets such as `- [MIN_SOUL_SCORE] — description`
28
+ * (section 7 of MINDFORGE.md). A bracketed key only counts when the bracket
29
+ * opens the line (leading whitespace allowed) and is followed by `=`.
30
+ *
31
+ * The legacy form deliberately allows NO leading whitespace, matching the two
32
+ * regexes it replaces (bin/validate-config.js:36, bin/models/model-router.js:48).
33
+ * That is a safety property, not an oversight: it stops indented `KEY=value`
34
+ * lines inside markdown code blocks from being read as configuration.
35
+ */
36
+
37
+ const fs = require('fs');
38
+
39
+ /** [KEY] = value — key must open the line; only spaces/tabs may precede it. */
40
+ const ASSIGN_RE = /^[ \t]*\[([A-Z0-9_]+)\][ \t]*=[ \t]*(.*)$/;
41
+
42
+ /** KEY=value — legacy plain form. Column 0 only. `(.*)`: empty values are legal. */
43
+ const LEGACY_RE = /^([A-Z0-9_]+)=(.*)$/;
44
+
45
+ const FENCE = '"""';
46
+
47
+ /** Drop a trailing ` # comment`. Requires whitespace before `#` so that
48
+ * values legitimately containing `#` (URL fragments, colours) survive. */
49
+ function stripComment(raw) {
50
+ const i = raw.search(/\s#/);
51
+ return i === -1 ? raw : raw.slice(0, i);
52
+ }
53
+
54
+ /** Unwrap "quoted" values and <markdown-autolink> URLs. */
55
+ function unwrap(v) {
56
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1);
57
+ if (v.length >= 2 && v.startsWith('<') && v.endsWith('>')) return v.slice(1, -1);
58
+ return v;
59
+ }
60
+
61
+ function normalise(raw) {
62
+ return unwrap(stripComment(raw).trim());
63
+ }
64
+
65
+ /**
66
+ * @param {string} content raw MINDFORGE.md text
67
+ * @returns {Record<string,string>} key -> normalised string value
68
+ */
69
+ function parseParams(content) {
70
+ const bracketed = {};
71
+ const legacy = {};
72
+ const lines = String(content).split(/\r?\n/);
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const m = lines[i].match(ASSIGN_RE);
76
+
77
+ if (!m) {
78
+ // Legacy plain form. Only reached when the line is not a bracketed
79
+ // assignment; the two patterns can never both match one line.
80
+ const g = lines[i].match(LEGACY_RE);
81
+ if (g) legacy[g[1]] = normalise(g[2]);
82
+ continue;
83
+ }
84
+
85
+ const key = m[1];
86
+ const rest = m[2].trim();
87
+
88
+ if (rest.startsWith(FENCE)) {
89
+ const inline = rest.slice(FENCE.length);
90
+ // Single-line fenced value: [K] = """text"""
91
+ if (inline.trimEnd().endsWith(FENCE) && inline.trim().length >= FENCE.length) {
92
+ bracketed[key] = inline.trimEnd().slice(0, -FENCE.length).trim();
93
+ continue;
94
+ }
95
+ const block = [];
96
+ let j = i + 1;
97
+ for (; j < lines.length; j++) {
98
+ if (lines[j].trim() === FENCE) break;
99
+ block.push(lines[j]);
100
+ }
101
+ bracketed[key] = block.join('\n').trim();
102
+ i = j; // resume after the closing fence (or at EOF)
103
+ continue;
104
+ }
105
+
106
+ bracketed[key] = normalise(rest);
107
+ }
108
+
109
+ // Bracketed is the canonical modern form and wins on collision, regardless
110
+ // of which appeared first in the file.
111
+ return { ...legacy, ...bracketed };
112
+ }
113
+
114
+ /**
115
+ * Read + parse a MINDFORGE.md. Returns {} when the file is absent so callers
116
+ * can fall back to their own defaults (fail-open on absence, not on garbage).
117
+ * @param {string} filePath
118
+ */
119
+ function readParams(filePath) {
120
+ if (!fs.existsSync(filePath)) return {};
121
+ return parseParams(fs.readFileSync(filePath, 'utf8'));
122
+ }
123
+
124
+ module.exports = { parseParams, readParams, ASSIGN_RE, LEGACY_RE };