agentxchain 2.144.0 → 2.146.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/dashboard/app.js CHANGED
@@ -15,6 +15,7 @@ import { render as renderCrossRepo } from './components/cross-repo.js';
15
15
  import { render as renderDelegations } from './components/delegations.js';
16
16
  import { render as renderBlockers } from './components/blockers.js';
17
17
  import { render as renderArtifacts } from './components/artifacts.js';
18
+ import { render as renderNotifications } from './components/notifications.js';
18
19
  import { render as renderMission } from './components/mission.js';
19
20
  import { render as renderChain } from './components/chain.js';
20
21
  import { render as renderRunHistory } from './components/run-history.js';
@@ -31,6 +32,7 @@ const VIEWS = {
31
32
  delegations: { fetch: ['state', 'history'], render: renderDelegations },
32
33
  ledger: { fetch: ['state', 'ledger', 'coordinatorState', 'coordinatorLedger', 'repoDecisionsSummary'], render: renderLedger },
33
34
  hooks: { fetch: ['audit', 'annotations', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderHooks },
35
+ notifications: { fetch: ['notifications'], render: renderNotifications },
34
36
  blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows', 'gateActions'], render: renderBlocked },
35
37
  gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers', 'gateActions'], render: renderGate },
36
38
  initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderInitiative },
@@ -62,6 +64,7 @@ const API_MAP = {
62
64
  coordinatorBlockers: '/api/coordinator/blockers',
63
65
  coordinatorRepoStatusRows: '/api/coordinator/repo-status',
64
66
  workflowKitArtifacts: '/api/workflow-kit-artifacts',
67
+ notifications: '/api/notifications',
65
68
  missions: '/api/missions',
66
69
  plans: '/api/plans',
67
70
  chainReports: '/api/chain-reports',
@@ -0,0 +1,127 @@
1
+ function esc(str) {
2
+ if (str == null) return '';
3
+ return String(str)
4
+ .replace(/&/g, '&')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
10
+
11
+ function badge(label, color = 'var(--text-dim)') {
12
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
13
+ }
14
+
15
+ function formatResult(entry) {
16
+ if (entry?.delivered) return badge('delivered', 'var(--green)');
17
+ if (entry?.timed_out) return badge('timed out', 'var(--yellow)');
18
+ return badge('failed', 'var(--red)');
19
+ }
20
+
21
+ function renderWebhookRow(webhook) {
22
+ return `<tr>
23
+ <td class="mono">${esc(webhook.name)}</td>
24
+ <td>${esc(webhook.timeout_ms)}</td>
25
+ <td>${esc(webhook.event_count)}</td>
26
+ <td><span class="mono">${esc((webhook.events || []).join(', '))}</span></td>
27
+ </tr>`;
28
+ }
29
+
30
+ function renderAuditRow(entry) {
31
+ const rowStyle = entry?.delivered ? '' : ' style="border-left:3px solid var(--red)"';
32
+ const statusCode = entry?.status_code == null ? '—' : String(entry.status_code);
33
+ const duration = entry?.duration_ms == null ? '—' : `${entry.duration_ms}ms`;
34
+ return `<tr${rowStyle}>
35
+ <td class="mono">${esc(entry?.emitted_at || '—')}</td>
36
+ <td><span class="mono">${esc(entry?.event_type || '—')}</span></td>
37
+ <td class="mono">${esc(entry?.notification_name || '—')}</td>
38
+ <td>${formatResult(entry)}</td>
39
+ <td>${esc(statusCode)}</td>
40
+ <td>${esc(duration)}</td>
41
+ <td>${esc(entry?.message || '—')}</td>
42
+ </tr>`;
43
+ }
44
+
45
+ export function render({ notifications }) {
46
+ if (!notifications) {
47
+ return `<div class="placeholder"><h2>Notifications</h2><p>No notification data available.</p></div>`;
48
+ }
49
+
50
+ if (notifications.ok === false) {
51
+ const hint = notifications.code === 'config_missing'
52
+ ? ' Run <code>agentxchain init --governed</code> to get started.'
53
+ : '';
54
+ return `<div class="placeholder"><h2>Notifications</h2><p>${esc(notifications.error || 'Failed to load notification data.')}${hint}</p></div>`;
55
+ }
56
+
57
+ const recent = Array.isArray(notifications.recent) ? notifications.recent : [];
58
+ const webhooks = Array.isArray(notifications.webhooks) ? notifications.webhooks : [];
59
+ const summary = notifications.summary || {};
60
+
61
+ if (!notifications.configured && recent.length === 0) {
62
+ return `<div class="placeholder"><h2>Notifications</h2><p>No <code>notifications.webhooks</code> are configured and no delivery audit entries exist yet.</p></div>`;
63
+ }
64
+
65
+ let html = `<div class="notifications-view"><div class="run-header"><div class="run-meta">`;
66
+ html += notifications.configured
67
+ ? badge(`${webhooks.length} webhook${webhooks.length === 1 ? '' : 's'} configured`, 'var(--green)')
68
+ : badge('not currently configured', 'var(--yellow)');
69
+ html += badge(`${summary.total_attempts || 0} attempts`, 'var(--accent)');
70
+ if ((summary.failed || 0) > 0) {
71
+ html += badge(`${summary.failed} failed`, 'var(--red)');
72
+ }
73
+ if ((summary.timed_out || 0) > 0) {
74
+ html += badge(`${summary.timed_out} timed out`, 'var(--yellow)');
75
+ }
76
+ if (notifications.approval_sla?.enabled) {
77
+ html += badge(`approval SLA: ${(notifications.approval_sla.reminder_after_seconds || []).join(', ')}s`, 'var(--accent)');
78
+ }
79
+ html += `</div></div>`;
80
+
81
+ if (webhooks.length > 0) {
82
+ html += `<div class="section"><h3>Notification Targets</h3>
83
+ <table class="data-table">
84
+ <thead>
85
+ <tr>
86
+ <th>Name</th>
87
+ <th>Timeout</th>
88
+ <th>Events</th>
89
+ <th>Subscribed Event Types</th>
90
+ </tr>
91
+ </thead>
92
+ <tbody>${webhooks.map(renderWebhookRow).join('')}</tbody>
93
+ </table>
94
+ </div>`;
95
+ }
96
+
97
+ html += `<div class="section"><h3>Delivery Summary</h3>
98
+ <p><strong>Delivered:</strong> ${esc(summary.delivered || 0)}<br>
99
+ <strong>Failed:</strong> ${esc(summary.failed || 0)}<br>
100
+ <strong>Last emitted:</strong> ${esc(summary.last_emitted_at || '—')}<br>
101
+ <strong>Last failure:</strong> ${esc(summary.last_failure_at || '—')}</p>
102
+ </div>`;
103
+
104
+ if (recent.length === 0) {
105
+ html += `<div class="section"><h3>Recent Delivery Attempts</h3><p style="color:var(--text-dim)">No notification deliveries recorded yet.</p></div>`;
106
+ } else {
107
+ html += `<div class="section"><h3>Recent Delivery Attempts</h3>
108
+ <table class="data-table">
109
+ <thead>
110
+ <tr>
111
+ <th>Emitted</th>
112
+ <th>Event</th>
113
+ <th>Target</th>
114
+ <th>Result</th>
115
+ <th>Status</th>
116
+ <th>Duration</th>
117
+ <th>Message</th>
118
+ </tr>
119
+ </thead>
120
+ <tbody>${recent.map(renderAuditRow).join('')}</tbody>
121
+ </table>
122
+ </div>`;
123
+ }
124
+
125
+ html += `</div>`;
126
+ return html;
127
+ }
@@ -401,6 +401,7 @@
401
401
  <a href="#delegations">Delegations</a>
402
402
  <a href="#ledger">Decisions</a>
403
403
  <a href="#hooks">Hooks</a>
404
+ <a href="#notifications">Notifications</a>
404
405
  <a href="#blocked">Blocked</a>
405
406
  <a href="#gate">Gates</a>
406
407
  <a href="#blockers">Blockers</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.144.0",
3
+ "version": "2.146.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,6 +69,8 @@ echo "AgentXchain Release Identity: ${TARGET_VERSION}"
69
69
  echo "============================================="
70
70
 
71
71
  TARGET_RELEASE_DOC="website-v2/docs/releases/v${TARGET_VERSION//./-}.mdx"
72
+ CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
73
+ REENTRY_MODE=0
72
74
  ALLOWED_RELEASE_PATHS=(
73
75
  "cli/CHANGELOG.md"
74
76
  "${TARGET_RELEASE_DOC}"
@@ -89,6 +91,10 @@ ALLOWED_RELEASE_PATHS=(
89
91
  "cli/homebrew/agentxchain.rb"
90
92
  "cli/homebrew/README.md"
91
93
  )
94
+ ALLOWED_REENTRY_VERSION_PATHS=(
95
+ "cli/package.json"
96
+ "cli/package-lock.json"
97
+ )
92
98
 
93
99
  is_allowed_release_path() {
94
100
  local candidate="$1"
@@ -112,15 +118,32 @@ stage_if_present() {
112
118
  fi
113
119
  }
114
120
 
115
- # 1. Assert only allowed release-surface dirt is present
116
- echo "[1/8] Checking release-prep tree state..."
121
+ # 1. Detect version/re-entry state before validating the tree
122
+ echo "[1/10] Checking current version..."
123
+ if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
124
+ REENTRY_MODE=1
125
+ echo " OK: package.json already targets ${TARGET_VERSION}; entering release re-entry mode"
126
+ else
127
+ echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
128
+ fi
129
+
130
+ # 2. Assert only allowed release-surface dirt is present
131
+ echo "[2/10] Checking release-prep tree state..."
117
132
  DISALLOWED_DIRTY=()
118
133
  while IFS= read -r status_line; do
119
134
  [[ -z "$status_line" ]] && continue
120
135
  path="${status_line#?? }"
121
- if ! is_allowed_release_path "$path"; then
122
- DISALLOWED_DIRTY+=("$path")
136
+ if is_allowed_release_path "$path"; then
137
+ continue
138
+ fi
139
+ if [[ "$REENTRY_MODE" -eq 1 ]]; then
140
+ for allowed in "${ALLOWED_REENTRY_VERSION_PATHS[@]}"; do
141
+ if [[ "$path" == "$allowed" ]]; then
142
+ continue 2
143
+ fi
144
+ done
123
145
  fi
146
+ DISALLOWED_DIRTY+=("$path")
124
147
  done < <(git -C "$REPO_ROOT" status --porcelain)
125
148
 
126
149
  if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
@@ -130,17 +153,8 @@ if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
130
153
  fi
131
154
  echo " OK: tree contains only allowed release-prep changes"
132
155
 
133
- # 2. Assert not already at target version
134
- echo "[2/8] Checking current version..."
135
- CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
136
- if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
137
- echo "FAIL: package.json is already at ${TARGET_VERSION}. Cannot double-bump." >&2
138
- exit 1
139
- fi
140
- echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
141
-
142
156
  # 3. Assert tag does not already exist
143
- echo "[3/8] Checking for existing tag..."
157
+ echo "[3/10] Checking for existing tag..."
144
158
  if git rev-parse "v${TARGET_VERSION}" >/dev/null 2>&1; then
145
159
  echo "FAIL: tag v${TARGET_VERSION} already exists. Delete it first or choose a different version." >&2
146
160
  exit 1
@@ -236,7 +250,11 @@ fi
236
250
 
237
251
  # 7. Update version files (no git operations)
238
252
  echo "[7/10] Updating version files..."
239
- npm version "$TARGET_VERSION" --no-git-tag-version
253
+ if [[ "$REENTRY_MODE" -eq 1 ]]; then
254
+ npm version "$TARGET_VERSION" --no-git-tag-version --allow-same-version
255
+ else
256
+ npm version "$TARGET_VERSION" --no-git-tag-version
257
+ fi
240
258
  echo " OK: package.json updated to ${TARGET_VERSION}"
241
259
 
242
260
  # 8. Stage version files
@@ -251,23 +269,58 @@ done
251
269
  git -C "$REPO_ROOT" add -- website-v2/docs/releases
252
270
  echo " OK: version files and allowed release surfaces staged"
253
271
 
254
- # 9. Create release commit
255
- echo "[9/10] Creating release commit..."
256
- git commit -m "${TARGET_VERSION}
272
+ # 9. Create or reuse release commit
273
+ if git diff --cached --quiet --exit-code; then
274
+ CURRENT_HEAD_SHA=$(git rev-parse HEAD)
275
+ echo "[9/10] Resolving re-entry release identity..."
276
+ COMMIT_MSG=$(git log -1 --format=%s)
277
+ if [[ "$COMMIT_MSG" == "$TARGET_VERSION" ]]; then
278
+ COMMIT_BODY=$(git log -1 --format=%B)
279
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
280
+ echo "FAIL: existing HEAD commit for re-entry is missing the required Co-Authored-By trailer" >&2
281
+ exit 1
282
+ fi
283
+ RELEASE_SHA=$(git rev-parse HEAD)
284
+ echo " OK: reusing existing release commit ${RELEASE_SHA:0:7}"
285
+ elif [[ "$REENTRY_MODE" -eq 1 ]]; then
286
+ echo " No staged release-surface deltas remain; creating metadata-only release identity commit for ${CURRENT_HEAD_SHA:0:7}"
287
+ git commit --allow-empty -m "${TARGET_VERSION}
288
+
289
+ Release-Base: ${CURRENT_HEAD_SHA}
290
+ Co-Authored-By: ${COAUTHORED_BY}"
291
+ RELEASE_SHA=$(git rev-parse HEAD)
292
+ COMMIT_BODY=$(git log -1 --format=%B)
293
+ if [[ "$COMMIT_BODY" != *"Release-Base: ${CURRENT_HEAD_SHA}"* ]]; then
294
+ echo "FAIL: metadata-only release identity commit is missing the required Release-Base line" >&2
295
+ exit 1
296
+ fi
297
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
298
+ echo "FAIL: metadata-only release identity commit is missing the required Co-Authored-By trailer" >&2
299
+ exit 1
300
+ fi
301
+ echo " OK: metadata-only release identity commit ${RELEASE_SHA:0:7} recorded base ${CURRENT_HEAD_SHA:0:7}"
302
+ else
303
+ echo "FAIL: no staged release-identity changes remain, and HEAD is not already the ${TARGET_VERSION} release commit. Found commit message '${COMMIT_MSG}'." >&2
304
+ exit 1
305
+ fi
306
+ else
307
+ echo "[9/10] Creating release commit..."
308
+ git commit -m "${TARGET_VERSION}
257
309
 
258
310
  Co-Authored-By: ${COAUTHORED_BY}"
259
- RELEASE_SHA=$(git rev-parse HEAD)
260
- COMMIT_MSG=$(git log -1 --format=%s)
261
- if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
262
- echo "FAIL: commit message is '${COMMIT_MSG}', expected '${TARGET_VERSION}'" >&2
263
- exit 1
264
- fi
265
- COMMIT_BODY=$(git log -1 --format=%B)
266
- if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
267
- echo "FAIL: release commit body is missing the required Co-Authored-By trailer" >&2
268
- exit 1
311
+ RELEASE_SHA=$(git rev-parse HEAD)
312
+ COMMIT_MSG=$(git log -1 --format=%s)
313
+ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
314
+ echo "FAIL: commit message is '${COMMIT_MSG}', expected '${TARGET_VERSION}'" >&2
315
+ exit 1
316
+ fi
317
+ COMMIT_BODY=$(git log -1 --format=%B)
318
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
319
+ echo "FAIL: release commit body is missing the required Co-Authored-By trailer" >&2
320
+ exit 1
321
+ fi
322
+ echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
269
323
  fi
270
- echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
271
324
 
272
325
  # 9.5. Inline preflight gate — tests, pack, and docs build must pass before tag
273
326
  if [[ "$SKIP_PREFLIGHT" -eq 1 ]]; then
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Release downstream truth — run after all downstream surfaces are updated.
3
- # Verifies: GitHub release exists, Homebrew tap SHA and URL match registry tarball.
3
+ # Verifies: GitHub release is published on the expected tag URL, Homebrew tap SHA and URL match registry tarball.
4
4
  # Usage: bash scripts/release-downstream-truth.sh --target-version <semver>
5
5
  set -uo pipefail
6
6
 
@@ -91,22 +91,30 @@ echo "[1/3] GitHub release"
91
91
  if ! command -v gh >/dev/null 2>&1; then
92
92
  fail "gh CLI not available — cannot verify GitHub release"
93
93
  else
94
- GH_FOUND=false
94
+ GH_READY=false
95
+ EXPECTED_GH_URL="https://github.com/shivamtiwari93/agentXchain.dev/releases/tag/v${TARGET_VERSION}"
95
96
  for attempt in $(seq 1 "$RETRY_ATTEMPTS"); do
96
97
  GH_TAG="$(gh release view "v${TARGET_VERSION}" --json tagName -q '.tagName' 2>/dev/null || true)"
97
- if [[ "$GH_TAG" == "v${TARGET_VERSION}" ]]; then
98
- GH_FOUND=true
98
+ GH_DRAFT="$(gh release view "v${TARGET_VERSION}" --json isDraft -q '.isDraft' 2>/dev/null || true)"
99
+ GH_URL="$(gh release view "v${TARGET_VERSION}" --json url -q '.url' 2>/dev/null || true)"
100
+ GH_PUBLISHED_AT="$(gh release view "v${TARGET_VERSION}" --json publishedAt -q '.publishedAt' 2>/dev/null || true)"
101
+ if [[ "$GH_TAG" == "v${TARGET_VERSION}" ]] \
102
+ && [[ "$GH_DRAFT" == "false" ]] \
103
+ && [[ "$GH_URL" == "$EXPECTED_GH_URL" ]] \
104
+ && [[ -n "$GH_PUBLISHED_AT" ]] \
105
+ && [[ "$GH_PUBLISHED_AT" != "null" ]]; then
106
+ GH_READY=true
99
107
  break
100
108
  fi
101
109
  if [[ "$attempt" -lt "$RETRY_ATTEMPTS" ]]; then
102
- echo " INFO: GitHub release not found (attempt ${attempt}/${RETRY_ATTEMPTS}); retrying in ${RETRY_DELAY_SECONDS}s..."
110
+ echo " INFO: GitHub release not ready (attempt ${attempt}/${RETRY_ATTEMPTS}); retrying in ${RETRY_DELAY_SECONDS}s..."
103
111
  sleep "$RETRY_DELAY_SECONDS"
104
112
  fi
105
113
  done
106
- if $GH_FOUND; then
107
- pass "GitHub release v${TARGET_VERSION} exists"
114
+ if $GH_READY; then
115
+ pass "GitHub release v${TARGET_VERSION} is published on the tagged release URL"
108
116
  else
109
- fail "GitHub release v${TARGET_VERSION} not found after ${RETRY_ATTEMPTS} attempts"
117
+ fail "GitHub release v${TARGET_VERSION} is not fully published (tag=${GH_TAG:-<missing>} draft=${GH_DRAFT:-<missing>} url=${GH_URL:-<missing>} publishedAt=${GH_PUBLISHED_AT:-<missing>})"
110
118
  fi
111
119
  fi
112
120
 
@@ -103,6 +103,68 @@ const DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME = Object.freeze({
103
103
  prompt_transport: 'stdin',
104
104
  });
105
105
 
106
+ const GOVERNED_GITIGNORE_LINES = Object.freeze([
107
+ '.env',
108
+ '.agentxchain/staging/',
109
+ '.agentxchain/dispatch/',
110
+ '.agentxchain/transactions/',
111
+ '.agentxchain/state.json',
112
+ '.agentxchain/session.json',
113
+ '.agentxchain/history.jsonl',
114
+ '.agentxchain/decision-ledger.jsonl',
115
+ '.agentxchain/repo-decisions.jsonl',
116
+ '.agentxchain/lock.json',
117
+ '.agentxchain/hook-audit.jsonl',
118
+ '.agentxchain/hook-annotations.jsonl',
119
+ '.agentxchain/run-history.jsonl',
120
+ '.agentxchain/events.jsonl',
121
+ '.agentxchain/notification-audit.jsonl',
122
+ '.agentxchain/schedule-state.json',
123
+ '.agentxchain/schedule-daemon.json',
124
+ '.agentxchain/continuous-session.json',
125
+ '.agentxchain/human-escalations.jsonl',
126
+ '.agentxchain/sla-reminders.json',
127
+ '.agentxchain/SESSION_RECOVERY.md',
128
+ '.agentxchain/migration-report.md',
129
+ '.agentxchain/intake/',
130
+ '.agentxchain/missions/',
131
+ '.agentxchain/multirepo/',
132
+ '.agentxchain/reviews/',
133
+ '.agentxchain/reports/',
134
+ '.agentxchain/proposed/',
135
+ 'TALK.md',
136
+ 'HUMAN_TASKS.md',
137
+ ]);
138
+
139
+ const GOVERNED_GITIGNORE_CONTENT = [
140
+ '# AgentXchain — secrets',
141
+ '.env',
142
+ '',
143
+ '# AgentXchain — transient execution artifacts (never commit)',
144
+ '.agentxchain/staging/',
145
+ '.agentxchain/dispatch/',
146
+ '.agentxchain/transactions/',
147
+ '',
148
+ '# AgentXchain — framework-owned state (gitignored by default in fresh scaffolds)',
149
+ '# These files remain durable on disk and in export/restore, but defaulting them',
150
+ '# out of raw `git status` reduces operator noise. Existing tracked copies still',
151
+ '# appear dirty until the repo explicitly untracks them.',
152
+ ...GOVERNED_GITIGNORE_LINES.slice(4),
153
+ ].join('\n') + '\n';
154
+
155
+ function ensureGitignoreEntries(gitignorePath, content, requiredEntries) {
156
+ if (!existsSync(gitignorePath)) {
157
+ writeFileSync(gitignorePath, content);
158
+ return;
159
+ }
160
+ const existingIgnore = readFileSync(gitignorePath, 'utf8');
161
+ const existingLines = new Set(existingIgnore.split(/\r?\n/));
162
+ const missing = requiredEntries.filter(entry => !existingLines.has(entry));
163
+ if (missing.length === 0) return;
164
+ const prefix = existingIgnore.endsWith('\n') || existingIgnore.length === 0 ? '' : '\n';
165
+ writeFileSync(gitignorePath, existingIgnore + prefix + missing.join('\n') + '\n');
166
+ }
167
+
106
168
  const GOVERNED_RUNTIMES = {
107
169
  'manual-pm': { type: 'manual' },
108
170
  'manual-dev': { type: 'manual' },
@@ -833,28 +895,10 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
833
895
  // TALK.md
834
896
  writeFileSync(join(dir, 'TALK.md'), `# ${projectName} — Team Talk File\n\nCanonical human-readable handoff log for all agents.\n\n---\n\n`);
835
897
 
836
- // .gitignore additions with inline comments so operators know what to commit vs. ignore
898
+ // .gitignore additions with inline comments so fresh governed repos keep
899
+ // framework-owned runtime state out of raw git status by default.
837
900
  const gitignorePath = join(dir, '.gitignore');
838
- const gitignoreContent = [
839
- '# AgentXchain — secrets',
840
- '.env',
841
- '',
842
- '# AgentXchain — transient execution artifacts (never commit)',
843
- '.agentxchain/staging/',
844
- '.agentxchain/dispatch/',
845
- '.agentxchain/transactions/',
846
- ].join('\n') + '\n';
847
- const requiredPaths = ['.env', '.agentxchain/staging/', '.agentxchain/dispatch/', '.agentxchain/transactions/'];
848
- if (!existsSync(gitignorePath)) {
849
- writeFileSync(gitignorePath, gitignoreContent);
850
- } else {
851
- const existingIgnore = readFileSync(gitignorePath, 'utf8');
852
- const missing = requiredPaths.filter(entry => !existingIgnore.split(/\r?\n/).includes(entry));
853
- if (missing.length > 0) {
854
- const prefix = existingIgnore.endsWith('\n') ? '' : '\n';
855
- writeFileSync(gitignorePath, existingIgnore + prefix + missing.join('\n') + '\n');
856
- }
857
- }
901
+ ensureGitignoreEntries(gitignorePath, GOVERNED_GITIGNORE_CONTENT, GOVERNED_GITIGNORE_LINES);
858
902
 
859
903
  return { config, state, scaffoldWorkflowKitConfig };
860
904
  }
@@ -1251,16 +1295,7 @@ export async function initCommand(opts) {
1251
1295
  writeFileSync(join(dir, 'HUMAN_TASKS.md'), '# Human Tasks\n\n(Agents append tasks here when they need human action.)\n');
1252
1296
  const gitignorePath = join(dir, '.gitignore');
1253
1297
  const requiredIgnores = ['.env', '.agentxchain-trigger.json', '.agentxchain-prompts/', '.agentxchain-workspaces/', '.agentxchain-watch.pid', '.agentxchain-autonudge.state'];
1254
- if (!existsSync(gitignorePath)) {
1255
- writeFileSync(gitignorePath, requiredIgnores.join('\n') + '\n');
1256
- } else {
1257
- const existingIgnore = readFileSync(gitignorePath, 'utf8');
1258
- const missing = requiredIgnores.filter(entry => !existingIgnore.split(/\r?\n/).includes(entry));
1259
- if (missing.length > 0) {
1260
- const prefix = existingIgnore.endsWith('\n') ? '' : '\n';
1261
- writeFileSync(gitignorePath, existingIgnore + prefix + missing.join('\n') + '\n');
1262
- }
1263
- }
1298
+ ensureGitignoreEntries(gitignorePath, requiredIgnores.join('\n') + '\n', requiredIgnores);
1264
1299
 
1265
1300
  // .planning/ structure
1266
1301
  mkdirSync(join(dir, '.planning', 'research'), { recursive: true });
@@ -13,13 +13,16 @@
13
13
  import chalk from 'chalk';
14
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
15
15
  import { join, dirname } from 'path';
16
- import { loadProjectContext } from '../lib/config.js';
16
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
17
17
  import {
18
18
  assignGovernedTurn,
19
19
  getActiveTurns,
20
20
  getActiveTurnCount,
21
21
  reactivateGovernedRun,
22
22
  detectStateBundleDesync,
23
+ normalizeGovernedStateShape,
24
+ reconcileApprovalPausesWithConfig,
25
+ reconcileRecoveryActionsWithConfig,
23
26
  STATE_PATH,
24
27
  HISTORY_PATH,
25
28
  LEDGER_PATH,
@@ -27,7 +30,6 @@ import {
27
30
  import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
28
31
  import { getDispatchTurnDir } from '../lib/turn-paths.js';
29
32
  import { consumeNextApprovedIntent } from '../lib/intake.js';
30
- import { loadProjectState } from '../lib/config.js';
31
33
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
32
34
  import { deriveRecommendedContinuityAction } from '../lib/continuity-status.js';
33
35
  import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
@@ -178,7 +180,20 @@ export async function restartCommand(opts) {
178
180
  process.exit(1);
179
181
  }
180
182
 
181
- const state = JSON.parse(readFileSync(statePath, 'utf8'));
183
+ let state;
184
+ try {
185
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
186
+ const normalized = normalizeGovernedStateShape(parsed);
187
+ const reconciledApprovals = reconcileApprovalPausesWithConfig(normalized.state, config);
188
+ const reconciledRecovery = reconcileRecoveryActionsWithConfig(reconciledApprovals.state, config);
189
+ state = reconciledRecovery.state;
190
+ if (normalized.changed || reconciledApprovals.changed || reconciledRecovery.changed) {
191
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
192
+ }
193
+ } catch {
194
+ console.log(chalk.red('No valid governed state.json found.'));
195
+ process.exit(1);
196
+ }
182
197
 
183
198
  // Load checkpoint (optional — restart can work without it, just with less context)
184
199
  const checkpoint = readSessionCheckpoint(root);
@@ -40,6 +40,7 @@ import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
40
40
  import { runHooks } from '../lib/hook-runner.js';
41
41
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
42
42
  import { consumeNextApprovedIntent } from '../lib/intake.js';
43
+ import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
43
44
 
44
45
  export async function resumeCommand(opts) {
45
46
  const context = loadProjectContext();
@@ -75,6 +76,17 @@ export async function resumeCommand(opts) {
75
76
  process.exit(1);
76
77
  }
77
78
 
79
+ const staleReconciliation = reconcileStaleTurns(root, state, config);
80
+ state = staleReconciliation.state || state;
81
+ if (staleReconciliation.ghost_turns.length > 0) {
82
+ printGhostTurnRecovery(staleReconciliation.ghost_turns);
83
+ process.exit(1);
84
+ }
85
+ if (staleReconciliation.stale_turns.length > 0) {
86
+ printStaleTurnRecovery(staleReconciliation.stale_turns);
87
+ process.exit(1);
88
+ }
89
+
78
90
  // §47: active + turns present → reject (resume assigns new turns, not re-dispatches)
79
91
  const activeCount = getActiveTurnCount(state);
80
92
  const activeTurns = getActiveTurns(state);
@@ -351,6 +363,32 @@ export async function resumeCommand(opts) {
351
363
  printDispatchSummary(state, config);
352
364
  }
353
365
 
366
+ function printGhostTurnRecovery(ghostTurns) {
367
+ console.log(chalk.red.bold('Ghost turn detected — subprocess never started.'));
368
+ console.log('');
369
+ for (const ghost of ghostTurns) {
370
+ const secs = Math.floor(ghost.running_ms / 1000);
371
+ console.log(` Turn: ${ghost.turn_id} (${ghost.role})`);
372
+ console.log(` Runtime: ${ghost.runtime_id}`);
373
+ console.log(` Age: ${secs}s with no subprocess output`);
374
+ console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${ghost.turn_id} --reason ghost`)}`);
375
+ console.log('');
376
+ }
377
+ }
378
+
379
+ function printStaleTurnRecovery(staleTurns) {
380
+ console.log(chalk.red.bold('Stale turn detected.'));
381
+ console.log('');
382
+ for (const stale of staleTurns) {
383
+ const mins = Math.floor(stale.running_ms / 60000);
384
+ console.log(` Turn: ${stale.turn_id} (${stale.role})`);
385
+ console.log(` Runtime: ${stale.runtime_id}`);
386
+ console.log(` Age: ${mins}m with no output`);
387
+ console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${stale.turn_id} --reason stale`)}`);
388
+ console.log('');
389
+ }
390
+ }
391
+
354
392
  // ── Helpers ─────────────────────────────────────────────────────────────────
355
393
 
356
394
  function printResumeRunContext({ root, state, config }) {