@geraldmaron/construct 1.0.8 → 1.0.10

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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * lib/hooks/_lib/specialist-agent.mjs — Detect whether a Construct specialist
3
+ * sub-agent is currently active.
4
+ *
5
+ * A specialist is "active" when one of:
6
+ * - process.env.CONSTRUCT_AGENT_ID is set (explicit fence dispatch)
7
+ * - ~/.cx/last-agent-<id>.json or ~/.cx/last-agent.json carries a
8
+ * timestamp inside the 10-minute fence window (mirrors guard-bash.mjs)
9
+ *
10
+ * Differentiates "subagent driving the session, hold the line" from "main
11
+ * session (likely a human), warn but allow".
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'fs';
15
+ import { join } from 'path';
16
+ import { homedir } from 'os';
17
+
18
+ const FENCE_WINDOW_MS = 10 * 60 * 1000;
19
+
20
+ export function isSpecialistAgentActive({ now = Date.now, home = homedir, env = process.env } = {}) {
21
+ if (env.CONSTRUCT_AGENT_ID) return true;
22
+ try {
23
+ const cxDir = join(home(), '.cx');
24
+ const id = String(env.CONSTRUCT_AGENT_ID || '').replace(/^cx-/, '');
25
+ const candidates = [];
26
+ if (id) candidates.push(join(cxDir, `last-agent-${id.replace(/[^a-z0-9._-]/gi, '_')}.json`));
27
+ candidates.push(join(cxDir, 'last-agent.json'));
28
+ for (const path of candidates) {
29
+ if (!existsSync(path)) continue;
30
+ const data = JSON.parse(readFileSync(path, 'utf8'));
31
+ const lastTs = data?.ts ? Date.parse(data.ts) : 0;
32
+ if (lastTs && (now() - lastTs) < FENCE_WINDOW_MS) return true;
33
+ }
34
+ } catch { /* tracker missing or unreadable — treat as no agent active */ }
35
+ return false;
36
+ }
@@ -9,6 +9,12 @@
9
9
  * template policy before the PR is opened
10
10
  * gh pr edit same body lint when --body / --body-file is changed
11
11
  *
12
+ * Template-policy severity is conditional: a hard block (exit 2) when a
13
+ * specialist sub-agent is active (CONSTRUCT_AGENT_ID set, or the cx-tracker
14
+ * shows a fresh dispatch in the last 10 minutes); a warning otherwise so a
15
+ * human driving the session can override editorial nits without ceremony.
16
+ * CI still enforces the same rules on the resulting PR.
17
+ *
12
18
  * Silent and instant when there's nothing to run.
13
19
  *
14
20
  * Bypasses (env vars):
@@ -24,6 +30,7 @@ import { execSync, spawn, spawnSync } from 'child_process';
24
30
  import { dirname, join, resolve } from 'path';
25
31
  import { tmpdir } from 'os';
26
32
  import { logHookFailure } from './_lib/log.mjs';
33
+ import { isSpecialistAgentActive } from './_lib/specialist-agent.mjs';
27
34
  import { emitRoleEvent } from '../roles/hook-emit.mjs';
28
35
 
29
36
  let input = {};
@@ -72,16 +79,21 @@ if ((isGhPrCreate || isGhPrEdit) && process.env.CONSTRUCT_SKIP_PR_LINT !== '1')
72
79
  });
73
80
  const out = (result.stdout?.toString() || '') + (result.stderr?.toString() || '');
74
81
  if (result.status !== 0) {
75
- process.stderr.write(`[pre-push-gate] PR body fails template policy:\n${out}\n`);
82
+ const agentActive = isSpecialistAgentActive();
83
+ const severity = agentActive ? 'fail' : 'warn';
84
+ const banner = agentActive
85
+ ? '[pre-push-gate] PR body fails template policy (specialist agent active — blocking):'
86
+ : '[pre-push-gate] PR body fails template policy (no specialist agent active — warning only):';
87
+ process.stderr.write(`${banner}\n${out}\n`);
76
88
  process.stderr.write(`Override: CONSTRUCT_SKIP_PR_LINT=1 <command>\n`);
77
89
  emitRoleEvent({
78
- type: 'push_gate.fail',
79
- summary: 'PR body fails template policy',
90
+ type: agentActive ? 'push_gate.fail' : 'push_gate.warn',
91
+ summary: `PR body fails template policy (${severity})`,
80
92
  hookInput: input,
81
- context: { command: isGhPrCreate ? 'gh pr create' : 'gh pr edit', detail: out.slice(0, 800) },
93
+ context: { command: isGhPrCreate ? 'gh pr create' : 'gh pr edit', detail: out.slice(0, 800), agentActive },
82
94
  });
83
95
  if (tmpFile) try { rmSync(dirname(tmpFile), { recursive: true, force: true }); } catch {}
84
- process.exit(2);
96
+ if (agentActive) process.exit(2);
85
97
  }
86
98
  } finally {
87
99
  if (tmpFile) try { rmSync(dirname(tmpFile), { recursive: true, force: true }); } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "type": "module",
5
5
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
6
6
  "bin": {
@@ -138,8 +138,9 @@ if (validationErrors.length > 0) {
138
138
 
139
139
  const DRY_RUN = process.argv.includes("--dry-run");
140
140
  const COMPRESS_PERSONAS = process.argv.includes("--compress-personas");
141
- const lockPath = path.join(root, ".cx", "sync.lock");
142
- const stagingDir = path.join(root, ".cx", "sync-staging");
141
+ const projectDir = process.argv.includes("--project") ? process.cwd() : null;
142
+ const lockPath = path.join(projectDir || root, ".cx", "sync.lock");
143
+ const stagingDir = path.join(projectDir || root, ".cx", "sync-staging");
143
144
 
144
145
  /** Acquire an exclusive lockfile. Aborts if already held by a live process. */
145
146
  function acquireLock() {
@@ -192,7 +193,7 @@ function writeFile(file, content) {
192
193
  }
193
194
 
194
195
  // Two-phase: write to staging, commit later.
195
- const rel = path.relative(root, file);
196
+ const rel = path.relative(projectDir || root, file);
196
197
  const stagingPath = path.join(stagingDir, rel);
197
198
  mkdirp(path.dirname(stagingPath));
198
199
  fs.writeFileSync(stagingPath, stamped);
@@ -1175,7 +1176,6 @@ function syncSkills() {
1175
1176
 
1176
1177
  // --- Main ---
1177
1178
 
1178
- const projectDir = process.argv.includes("--project") ? process.cwd() : null;
1179
1179
  const entries = buildEntries();
1180
1180
 
1181
1181
  if (COMPRESS_PERSONAS) {