agentxchain 2.102.0 → 2.104.0

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/README.md CHANGED
@@ -224,7 +224,7 @@ agentxchain step
224
224
  | `supervise` | Run `watch` plus optional macOS auto-nudge |
225
225
  | `claim` / `release` | Human override of legacy lock ownership |
226
226
  | `rebind` | Rebuild Cursor bindings |
227
- | `generate` | Regenerate VS Code agent files |
227
+ | `generate` | Regenerate VS Code agent files; use `generate planning` to restore scaffold-owned governed planning docs |
228
228
  | `branch` | Manage Cursor branch override for launches |
229
229
  | `doctor` | Check local environment and setup |
230
230
  | `stop` | Stop watch daemon and local sessions |
@@ -55,7 +55,7 @@ import { configCommand } from '../src/commands/config.js';
55
55
  import { updateCommand } from '../src/commands/update.js';
56
56
  import { watchCommand } from '../src/commands/watch.js';
57
57
  import { claimCommand, releaseCommand } from '../src/commands/claim.js';
58
- import { generateCommand } from '../src/commands/generate.js';
58
+ import { generateCommand, generatePlanningCommand } from '../src/commands/generate.js';
59
59
  import { doctorCommand } from '../src/commands/doctor.js';
60
60
  import { superviseCommand } from '../src/commands/supervise.js';
61
61
  import { validateCommand } from '../src/commands/validate.js';
@@ -223,11 +223,19 @@ program
223
223
  .option('--unset', 'Remove override and follow the active git branch automatically')
224
224
  .action(branchCommand);
225
225
 
226
- program
226
+ const generateCmd = program
227
227
  .command('generate')
228
- .description('Regenerate VS Code agent files (.agent.md, hooks) from agentxchain.json')
228
+ .description('Regenerate VS Code agent files, or governed planning artifacts via subcommands')
229
229
  .action(generateCommand);
230
230
 
231
+ generateCmd
232
+ .command('planning')
233
+ .description('Generate or restore scaffold-owned governed planning artifacts')
234
+ .option('--dry-run', 'Show which planning artifacts would be written without changing files')
235
+ .option('--force', 'Overwrite existing scaffold-owned planning artifacts')
236
+ .option('-j, --json', 'Output as JSON')
237
+ .action(generatePlanningCommand);
238
+
231
239
  program
232
240
  .command('watch')
233
241
  .description('Watch lock.json and coordinate agent turns (the referee)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.102.0",
3
+ "version": "2.104.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,10 +9,12 @@ CLI_DIR="${SCRIPT_DIR}/.."
9
9
  cd "$CLI_DIR"
10
10
 
11
11
  STRICT_MODE=0
12
+ PUBLISH_GATE=0
12
13
  TARGET_VERSION="2.0.0"
13
14
 
14
15
  usage() {
15
- echo "Usage: bash scripts/release-preflight.sh [--strict] [--target-version <semver>]" >&2
16
+ echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--target-version <semver>]" >&2
17
+ echo " --publish-gate Run only release-critical checks (no full test suite). Use in CI publish workflows." >&2
16
18
  }
17
19
 
18
20
  while [[ $# -gt 0 ]]; do
@@ -21,6 +23,11 @@ while [[ $# -gt 0 ]]; do
21
23
  STRICT_MODE=1
22
24
  shift
23
25
  ;;
26
+ --publish-gate)
27
+ PUBLISH_GATE=1
28
+ STRICT_MODE=1
29
+ shift
30
+ ;;
24
31
  --target-version)
25
32
  if [[ -z "${2:-}" ]]; then
26
33
  echo "Error: --target-version requires a semver argument" >&2
@@ -99,49 +106,86 @@ else
99
106
  fi
100
107
 
101
108
  # 3. Tests
102
- echo "[3/6] Test suite"
103
- # Install MCP example deps — tests start example servers as subprocesses
104
- for example_dir in "${CLI_DIR}/../examples/mcp-echo-agent" "${CLI_DIR}/../examples/mcp-http-echo-agent"; do
105
- if [[ -f "${example_dir}/package.json" && ! -d "${example_dir}/node_modules" ]]; then
106
- echo " Installing deps for $(basename "$example_dir")..."
107
- (cd "$example_dir" && env -u NODE_AUTH_TOKEN -u NPM_CONFIG_USERCONFIG npm install --ignore-scripts --userconfig /dev/null 2>&1) || true
109
+ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
110
+ echo "[3/6] Release-gate tests (targeted subset)"
111
+ # In publish-gate mode, run only release-critical tests to avoid CI hangs.
112
+ # The full test suite is a pre-tag responsibility, not a publish-time gate.
113
+ GATE_TESTS=(
114
+ test/release-preflight.test.js
115
+ test/release-docs-content.test.js
116
+ test/release-notes-gate.test.js
117
+ test/release-identity-hardening.test.js
118
+ test/normalized-config.test.js
119
+ test/conformance.test.js
120
+ )
121
+ GATE_TEST_ARGS=()
122
+ for t in "${GATE_TESTS[@]}"; do
123
+ if [[ -f "$t" ]]; then
124
+ GATE_TEST_ARGS+=("$t")
125
+ fi
126
+ done
127
+ if [[ ${#GATE_TEST_ARGS[@]} -eq 0 ]]; then
128
+ fail "No release-gate test files found"
129
+ else
130
+ if run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
131
+ TEST_STATUS=0
132
+ else
133
+ TEST_STATUS=$?
134
+ fi
135
+ NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
136
+ NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
137
+ if [ "$TEST_STATUS" -eq 0 ] && [ "${NODE_FAIL:-0}" = "0" ]; then
138
+ pass "${NODE_PASS:-?} release-gate tests passed, 0 failures"
139
+ else
140
+ fail "Release-gate tests failed"
141
+ printf '%s\n' "$TEST_OUTPUT" | tail -20
142
+ fi
108
143
  fi
109
- done
110
- if run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test; then
111
- TEST_STATUS=0
112
144
  else
113
- TEST_STATUS=$?
114
- fi
115
- TEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# pass / { print $3 }')"
116
- TEST_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# fail / { print $3 }')"
117
- if [ -z "${TEST_PASS:-}" ]; then
118
- VITEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
119
- NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
120
- if [ -n "${VITEST_PASS:-}" ] && [ -n "${NODE_PASS:-}" ]; then
121
- TEST_PASS="$((VITEST_PASS + NODE_PASS))"
122
- elif [ -n "${NODE_PASS:-}" ]; then
123
- TEST_PASS="${NODE_PASS}"
124
- elif [ -n "${VITEST_PASS:-}" ]; then
125
- TEST_PASS="${VITEST_PASS}"
145
+ echo "[3/6] Test suite"
146
+ # Install MCP example deps — tests start example servers as subprocesses
147
+ for example_dir in "${CLI_DIR}/../examples/mcp-echo-agent" "${CLI_DIR}/../examples/mcp-http-echo-agent"; do
148
+ if [[ -f "${example_dir}/package.json" && ! -d "${example_dir}/node_modules" ]]; then
149
+ echo " Installing deps for $(basename "$example_dir")..."
150
+ (cd "$example_dir" && env -u NODE_AUTH_TOKEN -u NPM_CONFIG_USERCONFIG npm install --ignore-scripts --userconfig /dev/null 2>&1) || true
151
+ fi
152
+ done
153
+ if run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test; then
154
+ TEST_STATUS=0
155
+ else
156
+ TEST_STATUS=$?
126
157
  fi
127
- fi
128
- if [ -z "${TEST_FAIL:-}" ]; then
129
- NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
130
- if [ -n "${NODE_FAIL:-}" ]; then
131
- TEST_FAIL="${NODE_FAIL}"
132
- elif printf '%s\n' "$TEST_OUTPUT" | grep -Eq '^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed'; then
133
- TEST_FAIL=0
158
+ TEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# pass / { print $3 }')"
159
+ TEST_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# fail / { print $3 }')"
160
+ if [ -z "${TEST_PASS:-}" ]; then
161
+ VITEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
162
+ NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
163
+ if [ -n "${VITEST_PASS:-}" ] && [ -n "${NODE_PASS:-}" ]; then
164
+ TEST_PASS="$((VITEST_PASS + NODE_PASS))"
165
+ elif [ -n "${NODE_PASS:-}" ]; then
166
+ TEST_PASS="${NODE_PASS}"
167
+ elif [ -n "${VITEST_PASS:-}" ]; then
168
+ TEST_PASS="${VITEST_PASS}"
169
+ fi
134
170
  fi
135
- fi
136
- if [ "$TEST_STATUS" -eq 0 ] && [ "${TEST_FAIL:-0}" = "0" ]; then
137
- if [ -n "${TEST_PASS:-}" ]; then
138
- pass "${TEST_PASS} tests passed, 0 failures"
171
+ if [ -z "${TEST_FAIL:-}" ]; then
172
+ NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
173
+ if [ -n "${NODE_FAIL:-}" ]; then
174
+ TEST_FAIL="${NODE_FAIL}"
175
+ elif printf '%s\n' "$TEST_OUTPUT" | grep -Eq '^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed'; then
176
+ TEST_FAIL=0
177
+ fi
178
+ fi
179
+ if [ "$TEST_STATUS" -eq 0 ] && [ "${TEST_FAIL:-0}" = "0" ]; then
180
+ if [ -n "${TEST_PASS:-}" ]; then
181
+ pass "${TEST_PASS} tests passed, 0 failures"
182
+ else
183
+ pass "npm test passed, 0 failures"
184
+ fi
139
185
  else
140
- pass "npm test passed, 0 failures"
186
+ fail "npm test failed"
187
+ printf '%s\n' "$TEST_OUTPUT" | tail -20
141
188
  fi
142
- else
143
- fail "npm test failed"
144
- printf '%s\n' "$TEST_OUTPUT" | tail -20
145
189
  fi
146
190
 
147
191
  # 4. CHANGELOG has target version
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { resolve } from 'path';
8
- import { existsSync } from 'fs';
8
+ import { existsSync, readFileSync } from 'fs';
9
9
  import chalk from 'chalk';
10
- import { readRepoDecisions, getActiveRepoDecisions, getRepoDecisionById } from '../lib/repo-decisions.js';
10
+ import { readRepoDecisions, getActiveRepoDecisions, getRepoDecisionById, resolveDecisionAuthority } from '../lib/repo-decisions.js';
11
11
 
12
12
  /**
13
13
  * @param {object} opts - { json?: boolean, all?: boolean, show?: string, dir?: string }
@@ -39,6 +39,18 @@ export async function decisionsCommand(opts) {
39
39
  console.log(` Phase: ${dec.phase || '—'}`);
40
40
  console.log(` Run: ${(dec.run_id || '—').slice(0, 16)}`);
41
41
  console.log(` Turn: ${(dec.turn_id || '—').slice(0, 16)}`);
42
+ console.log(` Durability: ${dec.durability || 'repo'}`);
43
+ // Show decision authority if config has it
44
+ const config = loadConfig(root);
45
+ if (config && dec.role) {
46
+ const auth = resolveDecisionAuthority(dec.role, config);
47
+ if (auth !== null && !(typeof auth === 'object' && auth.unknown)) {
48
+ console.log(` Authority: ${auth} (${dec.role})`);
49
+ }
50
+ }
51
+ if (dec.overrides) {
52
+ console.log(` Supersedes: ${chalk.yellow(dec.overrides)}`);
53
+ }
42
54
  console.log(` Created: ${dec.created_at || '—'}`);
43
55
  if (dec.overridden_by) {
44
56
  console.log(` Overridden: ${chalk.yellow(dec.overridden_by)}`);
@@ -69,7 +81,11 @@ export async function decisionsCommand(opts) {
69
81
  for (const dec of decisions) {
70
82
  const status = formatStatus(dec.status);
71
83
  const runShort = (dec.run_id || '').slice(0, 12);
72
- const override = dec.overridden_by ? chalk.dim(` → ${dec.overridden_by}`) : '';
84
+ const override = dec.overridden_by
85
+ ? chalk.dim(` → ${dec.overridden_by}`)
86
+ : dec.overrides
87
+ ? chalk.dim(` ← supersedes ${dec.overrides}`)
88
+ : '';
73
89
  console.log(` ${chalk.cyan(dec.id)} ${status} ${chalk.dim(dec.category)} ${dec.statement}${override}`);
74
90
  console.log(` ${chalk.dim(`by ${dec.role || '?'} in ${runShort || '?'}`)}`);
75
91
  }
@@ -92,3 +108,13 @@ function findProjectRoot(dir) {
92
108
  }
93
109
  return null;
94
110
  }
111
+
112
+ function loadConfig(root) {
113
+ const configPath = resolve(root, 'agentxchain.json');
114
+ if (!existsSync(configPath)) return null;
115
+ try {
116
+ return JSON.parse(readFileSync(configPath, 'utf8'));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
@@ -1,6 +1,10 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
1
3
  import chalk from 'chalk';
2
- import { loadConfig } from '../lib/config.js';
4
+ import { loadConfig, loadProjectContext } from '../lib/config.js';
3
5
  import { generateVSCodeFiles } from '../lib/generate-vscode.js';
6
+ import { loadGovernedTemplate } from '../lib/governed-templates.js';
7
+ import { buildGovernedPlanningArtifacts } from '../lib/planning-artifacts.js';
4
8
 
5
9
  export async function generateCommand() {
6
10
  const result = loadConfig();
@@ -42,3 +46,124 @@ export async function generateCommand() {
42
46
  console.log(chalk.dim(' Select an agent from the Chat dropdown to start a turn.'));
43
47
  console.log('');
44
48
  }
49
+
50
+ function failPlanningGenerate(message, opts = {}) {
51
+ if (opts.json) {
52
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
53
+ } else {
54
+ console.log(chalk.red(` ${message}`));
55
+ }
56
+ process.exit(1);
57
+ }
58
+
59
+ export async function generatePlanningCommand(opts = {}) {
60
+ const context = loadProjectContext();
61
+ if (!context) {
62
+ failPlanningGenerate('No valid agentxchain.json found. Run `agentxchain init --governed` first.', opts);
63
+ }
64
+
65
+ if (context.version !== 4) {
66
+ failPlanningGenerate('`generate planning` only works in governed repos.', opts);
67
+ }
68
+
69
+ const templateId = context.rawConfig.template || 'generic';
70
+ let template;
71
+ try {
72
+ template = loadGovernedTemplate(templateId);
73
+ } catch (err) {
74
+ failPlanningGenerate(err.message, opts);
75
+ }
76
+
77
+ const projectName = context.config?.project?.name || context.rawConfig?.project?.name || 'AgentXchain Project';
78
+ const artifacts = buildGovernedPlanningArtifacts({
79
+ projectName,
80
+ routing: context.config.routing || {},
81
+ roles: context.config.roles || {},
82
+ template,
83
+ workflowKitConfig: context.config.workflow_kit || null,
84
+ });
85
+
86
+ const created = [];
87
+ const overwritten = [];
88
+ const skippedExisting = [];
89
+
90
+ for (const artifact of artifacts) {
91
+ const absPath = join(context.root, artifact.path);
92
+ if (existsSync(absPath)) {
93
+ if (opts.force) {
94
+ overwritten.push(artifact.path);
95
+ } else {
96
+ skippedExisting.push(artifact.path);
97
+ continue;
98
+ }
99
+ } else {
100
+ created.push(artifact.path);
101
+ }
102
+
103
+ if (opts.dryRun) {
104
+ continue;
105
+ }
106
+
107
+ const parentDir = dirname(absPath);
108
+ if (!existsSync(parentDir)) {
109
+ mkdirSync(parentDir, { recursive: true });
110
+ }
111
+ writeFileSync(absPath, artifact.content);
112
+ }
113
+
114
+ const payload = {
115
+ ok: true,
116
+ mode: 'planning',
117
+ dry_run: Boolean(opts.dryRun),
118
+ force: Boolean(opts.force),
119
+ template: template.id,
120
+ project: projectName,
121
+ total_artifacts: artifacts.length,
122
+ created,
123
+ overwritten,
124
+ skipped_existing: skippedExisting,
125
+ };
126
+
127
+ if (opts.json) {
128
+ console.log(JSON.stringify(payload, null, 2));
129
+ return;
130
+ }
131
+
132
+ console.log('');
133
+ console.log(chalk.bold(' Generating governed planning artifacts...'));
134
+ console.log(chalk.dim(` Project: ${projectName}`));
135
+ console.log(chalk.dim(` Template: ${template.id}`));
136
+ console.log('');
137
+
138
+ if (created.length > 0) {
139
+ console.log(chalk.green(` ${opts.dryRun ? 'Would create' : 'Created'} ${created.length} artifact${created.length === 1 ? '' : 's'}:`));
140
+ for (const path of created) {
141
+ console.log(chalk.green(` ${path}`));
142
+ }
143
+ }
144
+
145
+ if (overwritten.length > 0) {
146
+ console.log(chalk.yellow(` ${opts.dryRun ? 'Would overwrite' : 'Overwrote'} ${overwritten.length} artifact${overwritten.length === 1 ? '' : 's'}:`));
147
+ for (const path of overwritten) {
148
+ console.log(chalk.yellow(` ${path}`));
149
+ }
150
+ }
151
+
152
+ if (skippedExisting.length > 0) {
153
+ console.log(chalk.dim(` Preserved ${skippedExisting.length} existing artifact${skippedExisting.length === 1 ? '' : 's'}:`));
154
+ for (const path of skippedExisting) {
155
+ console.log(chalk.dim(` ${path}`));
156
+ }
157
+ }
158
+
159
+ if (created.length === 0 && overwritten.length === 0) {
160
+ console.log(chalk.dim(` ${opts.force ? 'Nothing to overwrite.' : 'All scaffold-owned planning artifacts already exist.'}`));
161
+ }
162
+
163
+ if (opts.dryRun) {
164
+ console.log('');
165
+ console.log(chalk.dim(' No files were written. Re-run without `--dry-run` to apply.'));
166
+ }
167
+
168
+ console.log('');
169
+ }
@@ -5,8 +5,9 @@ import chalk from 'chalk';
5
5
  import inquirer from 'inquirer';
6
6
  import { CONFIG_FILE, LOCK_FILE, STATE_FILE } from '../lib/config.js';
7
7
  import { generateVSCodeFiles } from '../lib/generate-vscode.js';
8
- import { loadAllGovernedTemplates, loadGovernedTemplate, VALID_GOVERNED_TEMPLATE_IDS, buildSystemSpecContent } from '../lib/governed-templates.js';
8
+ import { loadAllGovernedTemplates, loadGovernedTemplate, VALID_GOVERNED_TEMPLATE_IDS } from '../lib/governed-templates.js';
9
9
  import { normalizeWorkflowKit, VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
10
+ import { buildGovernedPlanningArtifacts, interpolateTemplateContent } from '../lib/planning-artifacts.js';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const TEMPLATES_DIR = join(__dirname, '../templates');
@@ -47,24 +48,11 @@ function loadTemplates() {
47
48
  return templates;
48
49
  }
49
50
 
50
- function interpolateTemplateContent(contentTemplate, projectName) {
51
- return contentTemplate.replaceAll('{{project_name}}', projectName);
52
- }
53
-
54
51
  function appendPromptOverride(basePrompt, override) {
55
52
  if (!override || !override.trim()) return basePrompt;
56
53
  return `${basePrompt}\n\n---\n\n## Project-Type-Specific Guidance\n\n${override.trim()}\n`;
57
54
  }
58
55
 
59
- function appendAcceptanceHints(baseMatrix, acceptanceHints) {
60
- if (!Array.isArray(acceptanceHints) || acceptanceHints.length === 0) {
61
- return baseMatrix;
62
- }
63
-
64
- const hintLines = acceptanceHints.map((hint) => `- [ ] ${hint}`).join('\n');
65
- return `${baseMatrix}\n\n## Template Guidance\n${hintLines}\n`;
66
- }
67
-
68
56
  function findGitRoot(startDir) {
69
57
  let current = resolve(startDir);
70
58
  while (true) {
@@ -598,20 +586,6 @@ export async function resolveGovernedInitAnswers(opts, prompt = (questions) => i
598
586
  };
599
587
  }
600
588
 
601
- function generateWorkflowKitPlaceholder(artifact, projectName) {
602
- const filename = basename(artifact.path);
603
- const title = filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
604
-
605
- if (artifact.semantics === 'section_check' && artifact.semantics_config?.required_sections?.length) {
606
- const sections = artifact.semantics_config.required_sections
607
- .map(s => `${s}\n\n(Content here.)\n`)
608
- .join('\n');
609
- return `# ${title} — ${projectName}\n\n${sections}`;
610
- }
611
-
612
- return `# ${title} — ${projectName}\n\n(Operator fills this in.)\n`;
613
- }
614
-
615
589
  function cloneJsonCompatible(value) {
616
590
  return value == null ? value : JSON.parse(JSON.stringify(value));
617
591
  }
@@ -653,29 +627,6 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
653
627
  };
654
628
  }
655
629
 
656
- const PHASE_DISPLAY_NAMES = Object.freeze({
657
- qa: 'QA',
658
- });
659
-
660
- function formatPhaseDisplayName(phaseKey) {
661
- if (PHASE_DISPLAY_NAMES[phaseKey]) {
662
- return PHASE_DISPLAY_NAMES[phaseKey];
663
- }
664
- return phaseKey.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
665
- }
666
-
667
- function buildRoadmapPhaseTable(routing, roles) {
668
- const rows = Object.entries(routing).map(([phaseKey, phaseConfig]) => {
669
- const phaseName = formatPhaseDisplayName(phaseKey);
670
- const entryRole = phaseConfig.entry_role;
671
- const role = roles[entryRole];
672
- const goal = role?.mandate || phaseName;
673
- const status = phaseKey === Object.keys(routing)[0] ? 'In progress' : 'Pending';
674
- return `| ${phaseName} | ${goal} | ${status} |`;
675
- });
676
- return `| Phase | Goal | Status |\n|-------|------|--------|\n${rows.join('\n')}\n`;
677
- }
678
-
679
630
  function buildPlanningSummaryLines(template, workflowKitConfig) {
680
631
  const lines = [
681
632
  'PM_SIGNOFF.md / ROADMAP.md / SYSTEM_SPEC.md',
@@ -821,53 +772,20 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
821
772
  }
822
773
 
823
774
  // Planning artifacts
824
- writeFileSync(join(dir, '.planning', 'PM_SIGNOFF.md'), `# PM Signoff — ${projectName}\n\nApproved: NO\n\n> This scaffold starts blocked on purpose. Change this to \`Approved: YES\` only after a human reviews the planning artifacts and is ready to open the planning gate.\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`);
825
- writeFileSync(join(dir, '.planning', 'ROADMAP.md'), `# Roadmap — ${projectName}\n\n## Phases\n\n${buildRoadmapPhaseTable(routing, roles)}`);
826
- writeFileSync(join(dir, '.planning', 'SYSTEM_SPEC.md'), buildSystemSpecContent(projectName, template.system_spec_overlay));
827
- writeFileSync(join(dir, '.planning', 'IMPLEMENTATION_NOTES.md'), `# Implementation Notes — ${projectName}\n\n## Changes\n\n(Dev fills this during implementation)\n\n## Verification\n\n(Dev fills this during implementation)\n\n## Unresolved Follow-ups\n\n(Dev lists any known gaps, tech debt, or follow-up items here.)\n`);
828
- const baseAcceptanceMatrix = `# Acceptance Matrix — ${projectName}\n\n| Req # | Requirement | Acceptance criteria | Test status | Last tested | Status |\n|-------|-------------|-------------------|-------------|-------------|--------|\n| (QA fills this from ROADMAP.md) | | | | | |\n`;
829
- writeFileSync(
830
- join(dir, '.planning', 'acceptance-matrix.md'),
831
- appendAcceptanceHints(baseAcceptanceMatrix, template.acceptance_hints)
832
- );
833
- writeFileSync(join(dir, '.planning', 'ship-verdict.md'), `# Ship Verdict — ${projectName}\n\n## Verdict: PENDING\n\n## QA Summary\n\n(QA writes the final ship/no-ship assessment here.)\n\n## Open Blockers\n\n(List any blocking issues.)\n\n## Conditions\n\n(List any conditions for shipping.)\n`);
834
- writeFileSync(join(dir, '.planning', 'RELEASE_NOTES.md'), `# Release Notes — ${projectName}\n\n## User Impact\n\n(QA fills this during the QA phase)\n\n## Verification Summary\n\n(QA fills this during the QA phase)\n\n## Upgrade Notes\n\n(QA fills this during the QA phase)\n\n## Known Issues\n\n(QA fills this during the QA phase)\n`);
835
- for (const artifact of template.planning_artifacts) {
836
- writeFileSync(
837
- join(dir, '.planning', artifact.filename),
838
- interpolateTemplateContent(artifact.content_template, projectName)
839
- );
840
- }
841
-
842
- // Workflow-kit custom artifacts — only scaffold files from explicit workflow_kit config
843
- // that are not already handled by the default scaffold above
844
- if (scaffoldWorkflowKitConfig && scaffoldWorkflowKitConfig.phases && typeof scaffoldWorkflowKitConfig.phases === 'object') {
845
- const defaultScaffoldPaths = new Set([
846
- '.planning/PM_SIGNOFF.md',
847
- '.planning/ROADMAP.md',
848
- '.planning/SYSTEM_SPEC.md',
849
- '.planning/IMPLEMENTATION_NOTES.md',
850
- '.planning/acceptance-matrix.md',
851
- '.planning/ship-verdict.md',
852
- '.planning/RELEASE_NOTES.md',
853
- ]);
854
-
855
- for (const phaseConfig of Object.values(scaffoldWorkflowKitConfig.phases)) {
856
- if (!Array.isArray(phaseConfig.artifacts)) continue;
857
- for (const artifact of phaseConfig.artifacts) {
858
- if (!artifact.path || defaultScaffoldPaths.has(artifact.path)) continue;
859
- const absPath = join(dir, artifact.path);
860
- if (existsSync(absPath)) continue;
861
-
862
- // Ensure parent directory exists
863
- const parentDir = dirname(absPath);
864
- if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
865
-
866
- // Generate placeholder content based on semantics type
867
- const content = generateWorkflowKitPlaceholder(artifact, projectName);
868
- writeFileSync(absPath, content);
869
- }
775
+ for (const artifact of buildGovernedPlanningArtifacts({
776
+ projectName,
777
+ routing,
778
+ roles,
779
+ template,
780
+ workflowKitConfig: scaffoldWorkflowKitConfig,
781
+ })) {
782
+ const absPath = join(dir, artifact.path);
783
+ if (artifact.source === 'workflow_kit' && existsSync(absPath)) {
784
+ continue;
870
785
  }
786
+ const parentDir = dirname(absPath);
787
+ if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
788
+ writeFileSync(absPath, artifact.content);
871
789
  }
872
790
 
873
791
  // TALK.md
@@ -37,13 +37,19 @@ function listRoles(roles, roleIds, opts) {
37
37
  }
38
38
 
39
39
  if (opts.json) {
40
- const output = roleIds.map((id) => ({
41
- id,
42
- title: roles[id].title,
43
- mandate: roles[id].mandate,
44
- write_authority: roles[id].write_authority,
45
- runtime: roles[id].runtime,
46
- }));
40
+ const output = roleIds.map((id) => {
41
+ const entry = {
42
+ id,
43
+ title: roles[id].title,
44
+ mandate: roles[id].mandate,
45
+ write_authority: roles[id].write_authority,
46
+ runtime: roles[id].runtime,
47
+ };
48
+ if (typeof roles[id].decision_authority === 'number') {
49
+ entry.decision_authority = roles[id].decision_authority;
50
+ }
51
+ return entry;
52
+ });
47
53
  console.log(JSON.stringify(output, null, 2));
48
54
  return;
49
55
  }
@@ -56,7 +62,8 @@ function listRoles(roles, roleIds, opts) {
56
62
  : r.write_authority === 'proposed'
57
63
  ? chalk.yellow(r.write_authority)
58
64
  : chalk.dim(r.write_authority);
59
- console.log(` ${chalk.cyan(id)} ${r.title} [${authority}] ${chalk.dim(r.runtime)}`);
65
+ const decAuth = typeof r.decision_authority === 'number' ? chalk.dim(` dec:${r.decision_authority}`) : '';
66
+ console.log(` ${chalk.cyan(id)} — ${r.title} [${authority}${decAuth}] → ${chalk.dim(r.runtime)}`);
60
67
  }
61
68
  console.log('');
62
69
  console.log(chalk.dim(' Usage: agentxchain role show <role_id>\n'));
@@ -81,13 +88,17 @@ function showRole(roleId, roles, roleIds, opts) {
81
88
  const r = roles[roleId];
82
89
 
83
90
  if (opts.json) {
84
- console.log(JSON.stringify({
91
+ const entry = {
85
92
  id: roleId,
86
93
  title: r.title,
87
94
  mandate: r.mandate,
88
95
  write_authority: r.write_authority,
89
96
  runtime: r.runtime,
90
- }, null, 2));
97
+ };
98
+ if (typeof r.decision_authority === 'number') {
99
+ entry.decision_authority = r.decision_authority;
100
+ }
101
+ console.log(JSON.stringify(entry, null, 2));
91
102
  return;
92
103
  }
93
104
 
@@ -101,6 +112,9 @@ function showRole(roleId, roles, roleIds, opts) {
101
112
  console.log(` Title: ${r.title}`);
102
113
  console.log(` Mandate: ${r.mandate}`);
103
114
  console.log(` Authority: ${authority}`);
115
+ if (typeof r.decision_authority === 'number') {
116
+ console.log(` Decision: ${r.decision_authority}`);
117
+ }
104
118
  console.log(` Runtime: ${chalk.dim(r.runtime)}`);
105
119
  console.log('');
106
120
  }
@@ -618,7 +618,7 @@ function renderContext(state, config, root, turn, role) {
618
618
 
619
619
  // Repo-level decisions that persist across runs
620
620
  if (state.repo_decisions && state.repo_decisions.length > 0) {
621
- const repoDecMd = renderRepoDecisionsMarkdown(state.repo_decisions);
621
+ const repoDecMd = renderRepoDecisionsMarkdown(state.repo_decisions, config);
622
622
  if (repoDecMd) {
623
623
  lines.push(repoDecMd);
624
624
  }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
+ import { summarizeRepoDecisions } from './repo-decisions.js';
5
6
 
6
7
  const SUPPORTED_EXPORT_SCHEMA_VERSIONS = new Set(['0.2', '0.3']);
7
8
  const VALID_FILE_FORMATS = new Set(['json', 'jsonl', 'text']);
@@ -369,38 +370,18 @@ function verifyDelegationSummary(artifact, errors) {
369
370
  }
370
371
  }
371
372
 
372
- function buildExpectedRepoDecisionsSummary(files) {
373
+ function buildExpectedRepoDecisionsSummary(files, config = null) {
373
374
  const repoDecisionsData = files?.['.agentxchain/repo-decisions.jsonl']?.data;
374
375
  if (!Array.isArray(repoDecisionsData) || repoDecisionsData.length === 0) {
375
376
  return null;
376
377
  }
377
-
378
- const active = repoDecisionsData.filter((d) => d.status === 'active');
379
- const overridden = repoDecisionsData.filter((d) => d.status === 'overridden');
380
-
381
- return {
382
- total: repoDecisionsData.length,
383
- active_count: active.length,
384
- overridden_count: overridden.length,
385
- active: active.map((d) => ({
386
- id: d.id,
387
- category: d.category,
388
- statement: d.statement,
389
- role: d.role,
390
- run_id: d.run_id,
391
- })),
392
- overridden: overridden.map((d) => ({
393
- id: d.id,
394
- overridden_by: d.overridden_by,
395
- statement: d.statement,
396
- })),
397
- };
378
+ return summarizeRepoDecisions(repoDecisionsData, config);
398
379
  }
399
380
 
400
381
  function verifyRepoDecisionsSummary(artifact, errors) {
401
382
  const summary = artifact.summary?.repo_decisions;
402
383
  const hasFile = '.agentxchain/repo-decisions.jsonl' in (artifact.files || {});
403
- const expected = buildExpectedRepoDecisionsSummary(artifact.files);
384
+ const expected = buildExpectedRepoDecisionsSummary(artifact.files, artifact.config || null);
404
385
 
405
386
  if (summary === null && expected === null) {
406
387
  return;
package/src/lib/export.js CHANGED
@@ -8,7 +8,7 @@ import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-co
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
9
  import { normalizeRunProvenance } from './run-provenance.js';
10
10
  import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
11
- import { readRepoDecisions } from './repo-decisions.js';
11
+ import { readRepoDecisions, summarizeRepoDecisions } from './repo-decisions.js';
12
12
  import { RUN_EVENTS_PATH } from './run-events.js';
13
13
 
14
14
  const EXPORT_SCHEMA_VERSION = '0.3';
@@ -211,18 +211,8 @@ function buildDashboardSessionSummary(root) {
211
211
  };
212
212
  }
213
213
 
214
- export function buildRepoDecisionsSummary(root) {
215
- const all = readRepoDecisions(root);
216
- if (!all || all.length === 0) return null;
217
- const active = all.filter(d => d.status === 'active');
218
- const overridden = all.filter(d => d.status === 'overridden');
219
- return {
220
- total: all.length,
221
- active_count: active.length,
222
- overridden_count: overridden.length,
223
- active: active.map(d => ({ id: d.id, category: d.category, statement: d.statement, role: d.role, run_id: d.run_id })),
224
- overridden: overridden.map(d => ({ id: d.id, overridden_by: d.overridden_by, statement: d.statement })),
225
- };
214
+ export function buildRepoDecisionsSummary(root, config = null) {
215
+ return summarizeRepoDecisions(readRepoDecisions(root), config);
226
216
  }
227
217
 
228
218
  export function buildDelegationSummary(files) {
@@ -471,7 +461,7 @@ export function buildRunExport(startDir = process.cwd()) {
471
461
  coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
472
462
  dashboard_session: buildDashboardSessionSummary(root),
473
463
  delegation_summary: buildDelegationSummary(files),
474
- repo_decisions: buildRepoDecisionsSummary(root),
464
+ repo_decisions: buildRepoDecisionsSummary(root, rawConfig),
475
465
  },
476
466
  workspace: buildRunWorkspaceMetadata(root),
477
467
  files,
@@ -2437,7 +2437,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2437
2437
  if (turnResult.decisions && turnResult.decisions.length > 0) {
2438
2438
  for (const dec of turnResult.decisions) {
2439
2439
  if (dec.overrides) {
2440
- const overrideCheck = validateOverride(root, dec);
2440
+ const overrideCheck = validateOverride(root, { ...dec, role: dec.role || turnResult.role }, config);
2441
2441
  if (!overrideCheck.ok) {
2442
2442
  return {
2443
2443
  ok: false,
@@ -3359,6 +3359,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3359
3359
  category: dec.category,
3360
3360
  statement: dec.statement,
3361
3361
  rationale: dec.rationale,
3362
+ durability: dec.durability || 'repo',
3363
+ overrides: dec.overrides || null,
3362
3364
  status: 'active',
3363
3365
  overridden_by: null,
3364
3366
  created_at: now,
@@ -366,6 +366,11 @@ export function validateV4Config(data, projectRoot) {
366
366
  if (!VALID_WRITE_AUTHORITIES.includes(role.write_authority)) {
367
367
  errors.push(`Role "${id}": write_authority must be one of: ${VALID_WRITE_AUTHORITIES.join(', ')}`);
368
368
  }
369
+ if (role.decision_authority !== undefined && role.decision_authority !== null) {
370
+ if (!Number.isInteger(role.decision_authority) || role.decision_authority < 0 || role.decision_authority > 99) {
371
+ errors.push(`Role "${id}": decision_authority must be an integer between 0 and 99`);
372
+ }
373
+ }
369
374
  if (typeof role.runtime !== 'string' || !role.runtime.trim()) errors.push(`Role "${id}": runtime required`);
370
375
  }
371
376
  }
@@ -0,0 +1,131 @@
1
+ import { basename } from 'node:path';
2
+ import { buildSystemSpecContent } from './governed-templates.js';
3
+
4
+ export const GOVERNED_BASELINE_PLANNING_PATHS = Object.freeze([
5
+ '.planning/PM_SIGNOFF.md',
6
+ '.planning/ROADMAP.md',
7
+ '.planning/SYSTEM_SPEC.md',
8
+ '.planning/IMPLEMENTATION_NOTES.md',
9
+ '.planning/acceptance-matrix.md',
10
+ '.planning/ship-verdict.md',
11
+ '.planning/RELEASE_NOTES.md',
12
+ ]);
13
+
14
+ const PHASE_DISPLAY_NAMES = Object.freeze({
15
+ qa: 'QA',
16
+ });
17
+
18
+ function formatPhaseDisplayName(phaseKey) {
19
+ if (PHASE_DISPLAY_NAMES[phaseKey]) {
20
+ return PHASE_DISPLAY_NAMES[phaseKey];
21
+ }
22
+ return phaseKey.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
23
+ }
24
+
25
+ function buildRoadmapPhaseTable(routing, roles) {
26
+ const rows = Object.entries(routing).map(([phaseKey, phaseConfig]) => {
27
+ const phaseName = formatPhaseDisplayName(phaseKey);
28
+ const entryRole = phaseConfig.entry_role;
29
+ const role = roles[entryRole];
30
+ const goal = role?.mandate || phaseName;
31
+ const status = phaseKey === Object.keys(routing)[0] ? 'In progress' : 'Pending';
32
+ return `| ${phaseName} | ${goal} | ${status} |`;
33
+ });
34
+ return `| Phase | Goal | Status |\n|-------|------|--------|\n${rows.join('\n')}\n`;
35
+ }
36
+
37
+ export function interpolateTemplateContent(contentTemplate, projectName) {
38
+ return contentTemplate.replaceAll('{{project_name}}', projectName);
39
+ }
40
+
41
+ export function appendAcceptanceHints(baseMatrix, acceptanceHints) {
42
+ if (!Array.isArray(acceptanceHints) || acceptanceHints.length === 0) {
43
+ return baseMatrix;
44
+ }
45
+
46
+ const hintLines = acceptanceHints.map((hint) => `- [ ] ${hint}`).join('\n');
47
+ return `${baseMatrix}\n\n## Template Guidance\n${hintLines}\n`;
48
+ }
49
+
50
+ export function generateWorkflowKitPlaceholder(artifact, projectName) {
51
+ const filename = basename(artifact.path);
52
+ const title = filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
53
+
54
+ if (artifact.semantics === 'section_check' && artifact.semantics_config?.required_sections?.length) {
55
+ const sections = artifact.semantics_config.required_sections
56
+ .map((section) => `${section}\n\n(Content here.)\n`)
57
+ .join('\n');
58
+ return `# ${title} — ${projectName}\n\n${sections}`;
59
+ }
60
+
61
+ return `# ${title} — ${projectName}\n\n(Operator fills this in.)\n`;
62
+ }
63
+
64
+ export function buildGovernedPlanningArtifacts({ projectName, routing, roles, template, workflowKitConfig }) {
65
+ const artifacts = [
66
+ {
67
+ path: '.planning/PM_SIGNOFF.md',
68
+ source: 'core',
69
+ content: `# PM Signoff — ${projectName}\n\nApproved: NO\n\n> This scaffold starts blocked on purpose. Change this to \`Approved: YES\` only after a human reviews the planning artifacts and is ready to open the planning gate.\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`,
70
+ },
71
+ {
72
+ path: '.planning/ROADMAP.md',
73
+ source: 'core',
74
+ content: `# Roadmap — ${projectName}\n\n## Phases\n\n${buildRoadmapPhaseTable(routing, roles)}`,
75
+ },
76
+ {
77
+ path: '.planning/SYSTEM_SPEC.md',
78
+ source: 'core',
79
+ content: buildSystemSpecContent(projectName, template?.system_spec_overlay),
80
+ },
81
+ {
82
+ path: '.planning/IMPLEMENTATION_NOTES.md',
83
+ source: 'core',
84
+ content: `# Implementation Notes — ${projectName}\n\n## Changes\n\n(Dev fills this during implementation)\n\n## Verification\n\n(Dev fills this during implementation)\n\n## Unresolved Follow-ups\n\n(Dev lists any known gaps, tech debt, or follow-up items here.)\n`,
85
+ },
86
+ {
87
+ path: '.planning/acceptance-matrix.md',
88
+ source: 'core',
89
+ content: appendAcceptanceHints(
90
+ `# Acceptance Matrix — ${projectName}\n\n| Req # | Requirement | Acceptance criteria | Test status | Last tested | Status |\n|-------|-------------|-------------------|-------------|-------------|--------|\n| (QA fills this from ROADMAP.md) | | | | | |\n`,
91
+ template?.acceptance_hints,
92
+ ),
93
+ },
94
+ {
95
+ path: '.planning/ship-verdict.md',
96
+ source: 'core',
97
+ content: `# Ship Verdict — ${projectName}\n\n## Verdict: PENDING\n\n## QA Summary\n\n(QA writes the final ship/no-ship assessment here.)\n\n## Open Blockers\n\n(List any blocking issues.)\n\n## Conditions\n\n(List any conditions for shipping.)\n`,
98
+ },
99
+ {
100
+ path: '.planning/RELEASE_NOTES.md',
101
+ source: 'core',
102
+ content: `# Release Notes — ${projectName}\n\n## User Impact\n\n(QA fills this during the QA phase)\n\n## Verification Summary\n\n(QA fills this during the QA phase)\n\n## Upgrade Notes\n\n(QA fills this during the QA phase)\n\n## Known Issues\n\n(QA fills this during the QA phase)\n`,
103
+ },
104
+ ];
105
+
106
+ for (const artifact of template?.planning_artifacts || []) {
107
+ artifacts.push({
108
+ path: `.planning/${artifact.filename}`,
109
+ source: 'template',
110
+ content: interpolateTemplateContent(artifact.content_template, projectName),
111
+ });
112
+ }
113
+
114
+ const seenPaths = new Set(GOVERNED_BASELINE_PLANNING_PATHS);
115
+ if (workflowKitConfig?.phases && typeof workflowKitConfig.phases === 'object') {
116
+ for (const phaseConfig of Object.values(workflowKitConfig.phases)) {
117
+ if (!Array.isArray(phaseConfig.artifacts)) continue;
118
+ for (const artifact of phaseConfig.artifacts) {
119
+ if (!artifact.path || seenPaths.has(artifact.path)) continue;
120
+ seenPaths.add(artifact.path);
121
+ artifacts.push({
122
+ path: artifact.path,
123
+ source: 'workflow_kit',
124
+ content: generateWorkflowKitPlaceholder(artifact, projectName),
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ return artifacts;
131
+ }
@@ -37,6 +37,74 @@ export function getRepoDecisionById(root, decisionId) {
37
37
  return readRepoDecisions(root).find(d => d.id === decisionId) || null;
38
38
  }
39
39
 
40
+ export function getDecisionAuthorityMetadata(roleId, config) {
41
+ const resolved = resolveDecisionAuthority(roleId, config);
42
+ if (resolved === null) return null;
43
+ if (typeof resolved === 'object' && resolved.unknown) {
44
+ return {
45
+ level: resolved.level,
46
+ source: 'unknown_role',
47
+ role: roleId || null,
48
+ };
49
+ }
50
+ if (roleId === 'human') {
51
+ const explicitHumanAuthority = typeof config?.roles?.human?.decision_authority === 'number';
52
+ return {
53
+ level: resolved,
54
+ source: explicitHumanAuthority ? 'configured' : 'human_default',
55
+ role: roleId,
56
+ };
57
+ }
58
+ return {
59
+ level: resolved,
60
+ source: 'configured',
61
+ role: roleId || null,
62
+ };
63
+ }
64
+
65
+ export function summarizeRepoDecisions(decisions, config) {
66
+ if (!Array.isArray(decisions) || decisions.length === 0) return null;
67
+ const active = decisions.filter((d) => d.status === 'active');
68
+ const overridden = decisions.filter((d) => d.status === 'overridden');
69
+ const addAuthority = (decision) => {
70
+ const authority = getDecisionAuthorityMetadata(decision.role, config);
71
+ return {
72
+ id: decision.id,
73
+ category: decision.category,
74
+ statement: decision.statement,
75
+ role: decision.role,
76
+ run_id: decision.run_id,
77
+ overrides: decision.overrides || null,
78
+ durability: decision.durability || 'repo',
79
+ authority_level: authority?.level ?? null,
80
+ authority_source: authority?.source || null,
81
+ };
82
+ };
83
+ return {
84
+ total: decisions.length,
85
+ active_count: active.length,
86
+ overridden_count: overridden.length,
87
+ active: active.map(addAuthority),
88
+ overridden: overridden.map((d) => {
89
+ const authority = getDecisionAuthorityMetadata(d.role, config);
90
+ return {
91
+ id: d.id,
92
+ overridden_by: d.overridden_by,
93
+ statement: d.statement,
94
+ overrides: d.overrides || null,
95
+ durability: d.durability || 'repo',
96
+ role: d.role || null,
97
+ authority_level: authority?.level ?? null,
98
+ authority_source: authority?.source || null,
99
+ };
100
+ }),
101
+ };
102
+ }
103
+
104
+ export function buildRepoDecisionsSummary(decisions) {
105
+ return summarizeRepoDecisions(decisions, null);
106
+ }
107
+
40
108
  // ── Write ───────────────────────────────────────────────────────────────────
41
109
 
42
110
  export function appendRepoDecision(root, entry) {
@@ -62,7 +130,16 @@ export function overrideRepoDecision(root, targetId, overridingId) {
62
130
 
63
131
  // ── Validate Override ───────────────────────────────────────────────────────
64
132
 
65
- export function validateOverride(root, decision) {
133
+ /**
134
+ * Validate that an override is allowed.
135
+ * @param {string} root - project root
136
+ * @param {object} decision - the overriding decision (must have .overrides, .id, optionally .role)
137
+ * @param {object} [config] - agentxchain config (used for authority enforcement)
138
+ * @returns {{ ok: boolean, error?: string, warning?: string }}
139
+ *
140
+ * DEC-SPEC: .planning/DECISION_AUTHORITY_SPEC.md
141
+ */
142
+ export function validateOverride(root, decision, config) {
66
143
  if (!decision.overrides) return { ok: true };
67
144
  const targetId = decision.overrides;
68
145
  const target = getRepoDecisionById(root, targetId);
@@ -75,21 +152,104 @@ export function validateOverride(root, decision) {
75
152
  if (target.status !== 'active') {
76
153
  return { ok: false, error: `decisions: ${targetId} has status "${target.status}", only active repo decisions can be overridden.` };
77
154
  }
155
+
156
+ // Authority enforcement (opt-in via decision_authority on roles)
157
+ const authorityResult = checkOverrideAuthority(decision, target, config);
158
+ if (!authorityResult.ok) return authorityResult;
159
+
160
+ return authorityResult.warning ? { ok: true, warning: authorityResult.warning } : { ok: true };
161
+ }
162
+
163
+ /**
164
+ * Resolve the decision_authority level for a role.
165
+ * - 'human' defaults to 100 unless explicitly configured.
166
+ * - Unknown roles default to 0 (with warning).
167
+ * - Null means opt-out (no enforcement).
168
+ */
169
+ export function resolveDecisionAuthority(roleId, config) {
170
+ if (!config || !config.roles) return null;
171
+ if (roleId === 'human') {
172
+ const humanRole = config.roles.human;
173
+ if (humanRole && typeof humanRole.decision_authority === 'number') {
174
+ return humanRole.decision_authority;
175
+ }
176
+ return 100; // human default
177
+ }
178
+ const role = config.roles[roleId];
179
+ if (!role) return { level: 0, unknown: true };
180
+ if (typeof role.decision_authority !== 'number') return null;
181
+ return role.decision_authority;
182
+ }
183
+
184
+ /**
185
+ * Check whether the overriding role has sufficient authority to override
186
+ * a decision made by the target role.
187
+ */
188
+ function checkOverrideAuthority(overridingDecision, targetDecision, config) {
189
+ if (!config || !config.roles) return { ok: true };
190
+
191
+ const overridingRole = overridingDecision.role;
192
+ const targetRole = targetDecision.role;
193
+
194
+ // Same-role override is always allowed
195
+ if (overridingRole && targetRole && overridingRole === targetRole) return { ok: true };
196
+
197
+ const targetAuth = resolveDecisionAuthority(targetRole, config);
198
+ const overridingAuth = resolveDecisionAuthority(overridingRole, config);
199
+
200
+ // Handle unknown target role
201
+ let warning;
202
+ if (targetAuth && typeof targetAuth === 'object' && targetAuth.unknown) {
203
+ warning = `decisions: target decision role '${targetRole}' not found in current config, treating as authority 0.`;
204
+ // targetAuth is effectively 0, allow override
205
+ return { ok: true, warning };
206
+ }
207
+
208
+ // Opt-in: if either side is null (not configured), allow
209
+ if (targetAuth === null || overridingAuth === null) return { ok: true };
210
+
211
+ // Handle unknown overriding role (shouldn't normally happen, but be safe)
212
+ const overridingLevel = (typeof overridingAuth === 'object' && overridingAuth.unknown) ? 0 : overridingAuth;
213
+ const targetLevel = (typeof targetAuth === 'object') ? 0 : targetAuth;
214
+
215
+ if (overridingLevel < targetLevel) {
216
+ return {
217
+ ok: false,
218
+ error: `decisions: role '${overridingRole}' (authority ${overridingLevel}) cannot override ${targetDecision.id} made by '${targetRole}' (authority ${targetLevel}). Override requires authority >= ${targetLevel}.`,
219
+ };
220
+ }
221
+
78
222
  return { ok: true };
79
223
  }
80
224
 
81
225
  // ── Render ──────────────────────────────────────────────────────────────────
82
226
 
83
- export function renderRepoDecisionsMarkdown(activeDecisions) {
227
+ export function renderRepoDecisionsMarkdown(activeDecisions, config) {
84
228
  if (!activeDecisions || activeDecisions.length === 0) return '';
229
+ const hasAuthorityPolicy = Object.values(config?.roles || {}).some((role) => (
230
+ role && typeof role.decision_authority === 'number'
231
+ ));
85
232
  const lines = [
86
233
  '## Active Repo Decisions',
87
234
  '',
88
235
  'These decisions persist from prior governed runs. Comply or explicitly override with rationale.',
89
236
  '',
90
237
  ];
238
+ if (hasAuthorityPolicy) {
239
+ lines.push('When both roles declare `decision_authority`, overrides require authority greater than or equal to the originating role.');
240
+ lines.push('');
241
+ }
91
242
  for (const d of activeDecisions) {
92
- lines.push(`- **${d.id}** (${d.category}): ${d.statement}`);
243
+ const authority = getDecisionAuthorityMetadata(d.role, config);
244
+ const authorityText = authority
245
+ ? authority.source === 'human_default'
246
+ ? ' authority 100 (human default)'
247
+ : authority.source === 'unknown_role'
248
+ ? ' authority 0 (role no longer in config)'
249
+ : ` authority ${authority.level}`
250
+ : '';
251
+ const supersedes = d.overrides ? ` Supersedes ${d.overrides}.` : '';
252
+ lines.push(`- **${d.id}** (${d.category}, by ${d.role || 'unknown'}${authorityText}): ${d.statement}${supersedes}`);
93
253
  }
94
254
  lines.push('');
95
255
  return lines.join('\n');
package/src/lib/report.js CHANGED
@@ -1320,7 +1320,13 @@ export function formatGovernanceReportText(report) {
1320
1320
  lines.push('', 'Repo Decisions:');
1321
1321
  lines.push(` Active: ${run.repo_decisions.active_count} Overridden: ${run.repo_decisions.overridden_count}`);
1322
1322
  for (const d of run.repo_decisions.active) {
1323
- lines.push(` - ${d.id} (${d.category}): ${d.statement}`);
1323
+ const supersedes = d.overrides ? ` | supersedes ${d.overrides}` : '';
1324
+ const authority = d.authority_level == null ? '' : ` | authority ${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1325
+ lines.push(` - ${d.id} (${d.category}): ${d.statement}${supersedes}${authority}`);
1326
+ }
1327
+ for (const d of run.repo_decisions.overridden || []) {
1328
+ const authority = d.authority_level == null ? '' : ` | authority ${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1329
+ lines.push(` - ${d.id} (overridden by ${d.overridden_by || 'unknown'}${authority})`);
1324
1330
  }
1325
1331
  }
1326
1332
 
@@ -1825,10 +1831,20 @@ export function formatGovernanceReportMarkdown(report) {
1825
1831
  if (run.repo_decisions?.active?.length > 0) {
1826
1832
  lines.push('', '## Repo Decisions', '');
1827
1833
  lines.push(`Active: ${run.repo_decisions.active_count} | Overridden: ${run.repo_decisions.overridden_count}`, '');
1828
- lines.push('| ID | Category | Statement | Role | Run |', '|----|----------|-----------|------|-----|');
1834
+ lines.push('| ID | Category | Statement | Role | Authority | Run | Supersedes |', '|----|----------|-----------|------|-----------|-----|------------|');
1829
1835
  for (const d of run.repo_decisions.active) {
1830
1836
  const stmt = (d.statement || '').replace(/\|/g, '\\|');
1831
- lines.push(`| ${d.id} | ${d.category} | ${stmt} | ${d.role || ''} | \`${(d.run_id || '').slice(0, 12)}\` |`);
1837
+ const authority = d.authority_level == null ? '—' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1838
+ lines.push(`| ${d.id} | ${d.category} | ${stmt} | ${d.role || '—'} | ${authority} | \`${(d.run_id || '').slice(0, 12)}\` | ${d.overrides || '—'} |`);
1839
+ }
1840
+ if (run.repo_decisions.overridden?.length > 0) {
1841
+ lines.push('', 'Overridden decisions:', '');
1842
+ lines.push('| ID | Statement | Authority | Overridden By |', '|----|-----------|-----------|---------------|');
1843
+ for (const d of run.repo_decisions.overridden) {
1844
+ const stmt = (d.statement || '').replace(/\|/g, '\\|');
1845
+ const authority = d.authority_level == null ? '—' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`;
1846
+ lines.push(`| ${d.id} | ${stmt} | ${authority} | ${d.overridden_by || '—'} |`);
1847
+ }
1832
1848
  }
1833
1849
  }
1834
1850
 
@@ -2453,9 +2469,28 @@ function renderRunHtml(report) {
2453
2469
  if (run.repo_decisions?.active?.length > 0) {
2454
2470
  let rdHtml = `<p>Active: ${run.repo_decisions.active_count} | Overridden: ${run.repo_decisions.overridden_count}</p>`;
2455
2471
  rdHtml += htmlTable(
2456
- ['ID', 'Category', 'Statement', 'Role', 'Run'],
2457
- run.repo_decisions.active.map((d) => [esc(d.id), esc(d.category), esc(d.statement || ''), esc(d.role || '\u2014'), `<code>${esc((d.run_id || '').slice(0, 12))}</code>`]),
2472
+ ['ID', 'Category', 'Statement', 'Role', 'Authority', 'Run', 'Supersedes'],
2473
+ run.repo_decisions.active.map((d) => [
2474
+ esc(d.id),
2475
+ esc(d.category),
2476
+ esc(d.statement || ''),
2477
+ esc(d.role || '\u2014'),
2478
+ esc(d.authority_level == null ? '\u2014' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`),
2479
+ `<code>${esc((d.run_id || '').slice(0, 12))}</code>`,
2480
+ esc(d.overrides || '\u2014'),
2481
+ ]),
2458
2482
  );
2483
+ if (run.repo_decisions.overridden?.length > 0) {
2484
+ rdHtml += htmlTable(
2485
+ ['ID', 'Statement', 'Authority', 'Overridden By'],
2486
+ run.repo_decisions.overridden.map((d) => [
2487
+ esc(d.id),
2488
+ esc(d.statement || ''),
2489
+ esc(d.authority_level == null ? '\u2014' : `${d.authority_level}${d.authority_source === 'human_default' ? ' (human default)' : ''}`),
2490
+ esc(d.overridden_by || '\u2014'),
2491
+ ]),
2492
+ );
2493
+ }
2459
2494
  sections.push(`<div class="section">${htmlSection('Repo Decisions', rdHtml)}</div>`);
2460
2495
  }
2461
2496