@jentrix/cli 0.5.19 → 0.5.21

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "jentrix",
3
3
  "description": "Connect this Claude Code session to a Jentrix project: /jentrix-connect binds the current session (trusted lifecycle hooks supply the session id), /jentrix-align runs the MVP alignment wizard, /jentrix-status and /jentrix-end wrap the same session operations. Business logic lives in the Jentrix CLI — removing this plugin leaves `jentrix session attach` as the universal fallback.",
4
- "version": "0.5.2"
4
+ "version": "0.5.3"
5
5
  }
@@ -49,6 +49,15 @@ the plan, when a long investigation resolves, or before handing the work over.
49
49
  `jentrix task move --task <id> --to-column-id <id>`. Only the columns the
50
50
  board actually has.
51
51
 
52
+ **Cadence duty (evidence floor).** Checkpoint after EVERY commit, not only at
53
+ the end: push the `decision` (with `--basis`) the commit rested on the moment
54
+ it lands, and run its gates through `jentrix push log --from-cmd "<command>"`
55
+ so the exit code and output tail are recorded as a LOG. Record quality must
56
+ track the work, not end-of-session diligence — `jentrix session end` enforces
57
+ this (it refuses on commits with no decision record and on sectioned reports
58
+ with no typed artifacts; a deliberate exception is an ordinary
59
+ `jentrix push gap`).
60
+
52
61
  Note for the operator: this records the distillation. On a session aligned
53
62
  with `--capture`, the raw pre-compaction range is preserved separately and
54
63
  automatically by the `PreCompact` hook, so the bytes survive even when this
@@ -77,3 +77,17 @@ pipeline), the typed artifacts ARE the record — push them before closing:
77
77
  step 1, or in the next session. Do not try to reopen it.
78
78
  8. Ending the Jentrix session does NOT terminate this Claude Code session —
79
79
  say so if the user seems to expect it.
80
+
81
+ **Evidence floor.** `jentrix session end` enforces fixed checks and will
82
+ REFUSE to close when they are unmet: E1 — HEAD moved but no attested DIFF
83
+ (the CLI pushes the real patch itself; just re-run `session end` from the
84
+ aligned checkout); E2 — commits with no `jentrix push decision` and no
85
+ declared-deviation `jentrix push gap`; E3 — a REPORT carrying `## Learnings`
86
+ / `## Gaps` / `## Decisions` sections while the session has zero
87
+ corresponding typed artifacts (split them into `push learning|gap|decision`,
88
+ never one blob). Comply by pushing the named evidence and retrying, or
89
+ deviate honestly with a `gap` — `--acknowledge-evidence-gaps` closes anyway
90
+ and stamps each unmet check MISSING into Review readiness. Run every gate
91
+ through `jentrix push log --from-cmd "<command>"` (it records the exit code
92
+ and output tail as a LOG and exits with the command's own code), so "tests
93
+ green" claims carry evidence instead of tripping the E4 advisory.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jentrix",
3
- "version": "0.2.4",
4
- "description": "Connect Codex tasks to Jentrix projects with trusted lifecycle hooks, alignment, checkpoints, and honest session capture.",
3
+ "version": "0.2.6",
4
+ "description": "Connect Codex tasks to Jentrix with trusted lifecycle hooks, exact-rollout telemetry recovery, typed artifacts, and operator-confirmed alignment.",
5
5
  "author": {
6
6
  "name": "Jentrix",
7
7
  "url": "https://github.com/jentrix-au"
@@ -14,7 +14,7 @@
14
14
  "interface": {
15
15
  "displayName": "Jentrix",
16
16
  "shortDescription": "Connect this Codex task to Jentrix.",
17
- "longDescription": "Align a Codex task to a Jentrix project and task, preserve typed checkpoints, and capture supported lifecycle evidence through trusted hooks.",
17
+ "longDescription": "Align a Codex task to a Jentrix project and task, preserve typed artifacts and checkpoints, capture supported lifecycle evidence through trusted hooks, and recover provider-reported usage from the exact task rollout when hooks are unavailable.",
18
18
  "developerName": "Jentrix",
19
19
  "category": "Productivity",
20
20
  "capabilities": ["Interactive", "Write"],
@@ -1,12 +1,13 @@
1
1
  {
2
- "description": "Trusted Codex lifecycle bridge. Hook payloads are appended locally; compact hooks also ask the Jentrix CLI to preserve the approved capture boundary.",
2
+ "description": "Trusted Codex lifecycle bridge. Structured hook stdin is appended locally; credentials and transcript content never enter argv. Hooks remain inactive until the operator reviews and trusts them. PreCompact/PostCompact additionally preserve the approved capture boundary; capture off records only the boundary, never transcript content.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "stacks-runner session-hook --provider codex --event SessionStart"
9
+ "command": "stacks-runner session-hook --provider codex --event SessionStart",
10
+ "timeout": 3
10
11
  }
11
12
  ]
12
13
  }
@@ -16,7 +17,8 @@
16
17
  "hooks": [
17
18
  {
18
19
  "type": "command",
19
- "command": "stacks-runner session-hook --provider codex --event UserPromptSubmit"
20
+ "command": "stacks-runner session-hook --provider codex --event UserPromptSubmit",
21
+ "timeout": 3
20
22
  }
21
23
  ]
22
24
  }
@@ -26,7 +28,8 @@
26
28
  "hooks": [
27
29
  {
28
30
  "type": "command",
29
- "command": "stacks-runner session-hook --provider codex --event PostToolUse"
31
+ "command": "stacks-runner session-hook --provider codex --event PostToolUse",
32
+ "timeout": 3
30
33
  }
31
34
  ]
32
35
  }
@@ -36,7 +39,8 @@
36
39
  "hooks": [
37
40
  {
38
41
  "type": "command",
39
- "command": "stacks-runner session-hook --provider codex --event Stop"
42
+ "command": "stacks-runner session-hook --provider codex --event Stop",
43
+ "timeout": 3
40
44
  }
41
45
  ]
42
46
  }
@@ -47,12 +51,13 @@
47
51
  {
48
52
  "type": "command",
49
53
  "command": "stacks-runner session-hook --provider codex --event PreCompact",
50
- "timeout": 10
54
+ "timeout": 3
51
55
  },
52
56
  {
53
57
  "type": "command",
54
58
  "command": "jentrix session snapshot --event PreCompact",
55
- "timeout": 10
59
+ "timeout": 10,
60
+ "statusMessage": "Preserving the Jentrix capture boundary"
56
61
  }
57
62
  ]
58
63
  }
@@ -63,12 +68,13 @@
63
68
  {
64
69
  "type": "command",
65
70
  "command": "stacks-runner session-hook --provider codex --event PostCompact",
66
- "timeout": 10
71
+ "timeout": 3
67
72
  },
68
73
  {
69
74
  "type": "command",
70
75
  "command": "jentrix session snapshot --event PostCompact",
71
- "timeout": 10
76
+ "timeout": 10,
77
+ "statusMessage": "Recording the Jentrix compaction boundary"
72
78
  }
73
79
  ]
74
80
  }
@@ -78,7 +84,8 @@
78
84
  "hooks": [
79
85
  {
80
86
  "type": "command",
81
- "command": "stacks-runner session-hook --provider codex --event SessionEnd"
87
+ "command": "stacks-runner session-hook --provider codex --event SessionEnd",
88
+ "timeout": 3
82
89
  }
83
90
  ]
84
91
  }
@@ -34,6 +34,13 @@ description: Align the current Codex task to a Jentrix workspace, project, work
34
34
  8. Echo the returned server alignment snapshot verbatim. If `.mcp.json` changed,
35
35
  explain that the current task must reconnect MCP or restart before using it.
36
36
 
37
+ If detection names an exact Codex rollout fallback instead of a lifecycle-hook
38
+ record, say what that means: provider token receipts and the opening prompt remain
39
+ collectable, but prompt/tool/assistant lifecycle events are not. Direct the operator
40
+ to review and trust the plugin hooks with `/hooks`, then start a new Codex task for
41
+ full lifecycle coverage. Never claim `coverage: COMPLETE` for token receipts proves
42
+ that hooks ran.
43
+
37
44
  TRACE capture is off by default. Pass `--capture` only when the operator explicitly
38
45
  approved full hook-derived trace capture. Same-project re-alignment converges;
39
46
  different-project re-alignment must be refused until `jentrix session end`.
@@ -20,3 +20,10 @@ status is stale, read the real columns and correct it.
20
20
 
21
21
  The compact hooks preserve only the approved evidence boundary; this skill performs
22
22
  the model-authored distillation.
23
+
24
+ **Cadence duty (evidence floor).** Checkpoint after EVERY commit, not only at the
25
+ end: push the `decision` (with `--basis`) the commit rested on the moment it lands,
26
+ and run its gates through `jentrix push log --from-cmd "<command>"` so the exit code
27
+ and output tail land as a LOG. `jentrix session end` enforces this — it refuses on
28
+ commits with no decision record and on sectioned reports with no typed artifacts;
29
+ a deliberate exception is an ordinary `jentrix push gap`.
@@ -18,5 +18,10 @@ trusted environment and lifecycle-hook context. Relay the command output or erro
18
18
  verbatim. The command starts the hook-watching host in the background and prints
19
19
  its pid; do not start a second host. Never put credentials on the command line.
20
20
 
21
- If trusted identity is unavailable, report the CLI's fallback exactly; do not guess
22
- from Codex session files.
21
+ When lifecycle hooks are unavailable, the CLI may recover telemetry only from the
22
+ exact `rollout-*-${CODEX_THREAD_ID}.jsonl` under the trusted Codex home. Accept that
23
+ exact match; never choose the newest session file. Explain that the fallback keeps
24
+ provider token receipts and opening-prompt provenance, but not prompt/tool/assistant
25
+ lifecycle events. Direct the operator to review and trust the plugin hooks with
26
+ `/hooks`, then start a new Codex task for full lifecycle coverage. If trusted task
27
+ identity or the exact rollout is unavailable, relay the refusal and do not guess.
@@ -29,3 +29,16 @@ description: End a connected Jentrix session after preserving approved outputs,
29
29
  the command and must not be claimed as captured.
30
30
  8. The Jentrix session is sealed after end; do not retry pushes against it. Ending it
31
31
  does not terminate the Codex task.
32
+
33
+ **Evidence floor.** `jentrix session end` REFUSES to close on unmet checks: E1 —
34
+ HEAD moved but no attested DIFF (the CLI pushes the real patch itself; re-run
35
+ `session end` from the aligned checkout); E2 — commits with no `jentrix push
36
+ decision` and no declared-deviation `jentrix push gap`; E3 — a REPORT carrying
37
+ `## Learnings` / `## Gaps` / `## Decisions` sections while the session has zero
38
+ corresponding typed artifacts (split them into `push learning|gap|decision`).
39
+ Comply by pushing the named evidence and retrying, or deviate honestly with a
40
+ `gap`; `--acknowledge-evidence-gaps` closes anyway and stamps each unmet check
41
+ MISSING into Review readiness. Run every gate through
42
+ `jentrix push log --from-cmd "<command>"` (records exit code + output tail as a
43
+ LOG, exits with the command's own code) so gate claims carry evidence instead of
44
+ tripping the E4 advisory.
@@ -8,4 +8,8 @@ description: Show the connected Jentrix session, alignment, capture health, and
8
8
 
9
9
  Run `jentrix session status` and append the session id when the user supplied one.
10
10
  Relay stdout and stderr verbatim, including capture or telemetry warnings. Never turn
11
- null token values into zero or infer provider usage that the hooks did not report.
11
+ null token values into zero or infer provider usage that Codex did not report.
12
+ `coverage: COMPLETE` describes provider token-receipt coverage only; it does not prove
13
+ the lifecycle hooks ran. If the session uses the exact-rollout fallback, report that
14
+ prompt/tool/assistant lifecycle evidence is unavailable and point to `/hooks` plus a
15
+ new Codex task for future full coverage.
package/dist/main.js CHANGED
@@ -10324,7 +10324,7 @@ var require_dist = __commonJS({
10324
10324
  });
10325
10325
 
10326
10326
  // src/main.ts
10327
- import { spawn as spawn3 } from "node:child_process";
10327
+ import { spawn as spawn4 } from "node:child_process";
10328
10328
  import { createInterface } from "node:readline";
10329
10329
  import {
10330
10330
  appendFileSync,
@@ -20966,7 +20966,7 @@ function refreshAccessToken(input) {
20966
20966
 
20967
20967
  // src/client.ts
20968
20968
  var CLI_NAME = "stacks-cli";
20969
- var CLI_VERSION = "0.5.19";
20969
+ var CLI_VERSION = "0.5.21";
20970
20970
  var DEAD_TOKEN_MESSAGE = "authentication failed (HTTP 401): the token is dead or expired \u2014 mint a new PAT at /account/tokens on your Jentrix server and update STACKS_TOKEN (or --token / the config file).";
20971
20971
  var RELOGIN_MESSAGE = "OAuth session expired and could not be refreshed \u2014 run `jentrix login` to sign in again.";
20972
20972
  function originOf2(url2) {
@@ -21302,6 +21302,7 @@ function safeStringify(value) {
21302
21302
  }
21303
21303
 
21304
21304
  // src/commands/push.ts
21305
+ import { spawn as spawn2 } from "node:child_process";
21305
21306
  import { readFileSync as readFileSync4 } from "node:fs";
21306
21307
 
21307
21308
  // src/align-questions.ts
@@ -22584,8 +22585,9 @@ async function runSessionStart(provider, flags, deps2) {
22584
22585
  // outlives the CLI holding a dead credential.
22585
22586
  bearer: deps2.resolveTarget().token,
22586
22587
  // …and the config file itself, so the host FOLLOWS later rotations
22587
- // instead of dying with a revoked snapshot (2026-08-08 finding).
22588
- configPath: deps2.configPath,
22588
+ // instead of dying with a revoked snapshot (2026-08-08 finding)
22589
+ // but ONLY when the token IS the config's (hostConfigPathOf).
22590
+ configPath: hostConfigPathOf(deps2),
22589
22591
  repoRoot: inspection.root,
22590
22592
  installationId,
22591
22593
  mode: "launch",
@@ -22663,6 +22665,23 @@ function hooksDir(deps2, provider) {
22663
22665
  function codexHooksDir(deps2) {
22664
22666
  return hooksDir(deps2, "codex");
22665
22667
  }
22668
+ function readCodexRolloutPath(deps2, sessionId) {
22669
+ const configuredHome = deps2.env.CODEX_HOME?.trim() || null;
22670
+ const userHome = deps2.env.HOME ?? deps2.env.USERPROFILE;
22671
+ const home = configuredHome ?? (userHome ? join2(userHome, ".codex") : null);
22672
+ if (!home) return null;
22673
+ const root = join2(home, "sessions");
22674
+ const suffix = `-${sessionId}.jsonl`;
22675
+ try {
22676
+ const relative = readdirSync(root, {
22677
+ encoding: "utf8",
22678
+ recursive: true
22679
+ }).find((path) => path.endsWith(suffix));
22680
+ return relative ? join2(root, relative) : null;
22681
+ } catch {
22682
+ return null;
22683
+ }
22684
+ }
22666
22685
  function readClaudeHookContext(deps2, readFile = (path) => readFileSync2(path, "utf8"), mtimeOf = transcriptMtime, now = Date.now) {
22667
22686
  const own = deps2.env[CLAUDE_SESSION_ENV]?.trim() || null;
22668
22687
  const home = deps2.env.HOME ?? deps2.env.USERPROFILE;
@@ -22744,11 +22763,12 @@ function readCodexHookContext(deps2, readFile = (path) => readFileSync2(path, "u
22744
22763
  const { starts, endedAt } = ledger;
22745
22764
  if (own) {
22746
22765
  const source = threadId ? CODEX_THREAD_ENV : CODEX_SESSION_ENV;
22747
- const transcriptPath = transcriptForSessionIn(ledger, own);
22766
+ const hookTranscript = transcriptForSessionIn(ledger, own);
22767
+ const transcriptPath = hookTranscript ?? readCodexRolloutPath(deps2, own);
22748
22768
  return {
22749
22769
  sessionId: own,
22750
22770
  transcriptPath,
22751
- basis: `${source}=${own} \u2014 this Codex task's own id${transcriptPath ? " (rollout from its hook record)" : ""}`,
22771
+ basis: `${source}=${own} \u2014 this Codex task's own id${hookTranscript ? " (rollout from its hook record)" : transcriptPath ? " (rollout resolved from Codex sessions)" : ""}`,
22752
22772
  newerElsewhere: null
22753
22773
  };
22754
22774
  }
@@ -22771,7 +22791,7 @@ function readCodexHookContext(deps2, readFile = (path) => readFileSync2(path, "u
22771
22791
  const match = matches[0];
22772
22792
  return match ? {
22773
22793
  sessionId: match.sessionId,
22774
- transcriptPath: transcriptForSessionIn(ledger, match.sessionId),
22794
+ transcriptPath: transcriptForSessionIn(ledger, match.sessionId) ?? readCodexRolloutPath(deps2, match.sessionId),
22775
22795
  basis: `unambiguous SessionStart${match.at ? ` at ${match.at}` : ""} in ${match.cwd}`,
22776
22796
  newerElsewhere: null
22777
22797
  } : null;
@@ -22861,6 +22881,8 @@ async function runSessionAttach(flags, deps2) {
22861
22881
  }
22862
22882
  } else if (flags.provider === "claude" && flags.providerSession && !flags.transcriptPath) {
22863
22883
  flags.transcriptPath = readClaudeHookTranscript(deps2, flags.providerSession) ?? void 0;
22884
+ } else if (flags.provider === "codex" && flags.providerSession && !flags.transcriptPath) {
22885
+ flags.transcriptPath = readCodexRolloutPath(deps2, flags.providerSession) ?? void 0;
22864
22886
  }
22865
22887
  if (flags.provider === "codex") hookDir = codexHooksDir(deps2);
22866
22888
  if (!flags.providerSession) {
@@ -22924,8 +22946,9 @@ async function runSessionAttach(flags, deps2) {
22924
22946
  // outlives the CLI holding a dead credential.
22925
22947
  bearer: deps2.resolveTarget().token,
22926
22948
  // …and the config file itself, so the host FOLLOWS later rotations
22927
- // instead of dying with a revoked snapshot (2026-08-08 finding).
22928
- configPath: deps2.configPath,
22949
+ // instead of dying with a revoked snapshot (2026-08-08 finding)
22950
+ // but ONLY when the token IS the config's (hostConfigPathOf).
22951
+ configPath: hostConfigPathOf(deps2),
22929
22952
  repoRoot: inspection.root,
22930
22953
  installationId,
22931
22954
  mode: "watch",
@@ -23006,6 +23029,8 @@ async function runSessionStatus(sessionId, flags, deps2) {
23006
23029
  `Project: ${String(session.projectName)} (${String(session.projectId)})`,
23007
23030
  `Repository: ${String(session.repoOwnerName)}`,
23008
23031
  `Capture: ${capture}`,
23032
+ // Evidence floor (D3): the skeleton mode the alignment declared.
23033
+ `Skeleton: ${session.alignment?.skeleton === "off" ? "off (--no-skeleton at align)" : "on (content-free activity counts/timing)"}`,
23009
23034
  ...local,
23010
23035
  telemetry.line,
23011
23036
  `Summary artifact: ${session.summaryArtifactId ? String(session.summaryArtifactId) : "\u2014"}`
@@ -23102,6 +23127,87 @@ function telemetryVerdict(sessionId, usage, hostRan, provider = "claude") {
23102
23127
  warning: `NO TOKEN TELEMETRY: no local session host ever ran for ${sessionId}, so nothing could collect provider usage receipts. Align from inside the provider session (the plugin's hooks record the transcript), or pass --transcript-path.`
23103
23128
  };
23104
23129
  }
23130
+ function hostConfigPathOf(deps2) {
23131
+ const source = deps2.resolveTarget().tokenSource;
23132
+ return source === void 0 || source === "file" ? deps2.configPath : null;
23133
+ }
23134
+ var MAX_ATTESTED_DIFF_BYTES = 256 * 1024;
23135
+ async function buildAttestedDiffBody(git, root, startHead, endHead, dirty) {
23136
+ const counted = await git(
23137
+ ["rev-list", "--count", `${startHead}..${endHead}`],
23138
+ root
23139
+ );
23140
+ if (counted.code !== 0) return null;
23141
+ const commitCount = Number(counted.stdout.trim());
23142
+ if (!Number.isFinite(commitCount) || commitCount === 0) return null;
23143
+ const header = [
23144
+ "Attested delivery \u2014 generated by `jentrix session end` from the aligned checkout (source: cli).",
23145
+ `Range: ${startHead}..${endHead} \xB7 ${commitCount} commit(s)`,
23146
+ ""
23147
+ ].join("\n");
23148
+ const cap = MAX_ATTESTED_DIFF_BYTES - Buffer.byteLength(header, "utf8");
23149
+ const full = await git(
23150
+ ["log", "--patch", "--stat", `${startHead}..${endHead}`],
23151
+ root
23152
+ );
23153
+ let body;
23154
+ if (full.code === 0 && Buffer.byteLength(full.stdout, "utf8") <= cap) {
23155
+ body = full.stdout;
23156
+ } else {
23157
+ const stat = await git(["log", "--stat", `${startHead}..${endHead}`], root);
23158
+ const notice = "[truncated stat-first: the full --patch output exceeded the 256 KB attested-diff cap \u2014 commit list + per-file stat retained; the full patch lives in git]\n\n";
23159
+ let kept = stat.code === 0 ? stat.stdout : "(git log --stat failed)";
23160
+ const room = cap - Buffer.byteLength(notice, "utf8");
23161
+ if (Buffer.byteLength(kept, "utf8") > room) {
23162
+ kept = Buffer.from(kept, "utf8").subarray(0, Math.max(0, room - 64)).toString("utf8").replace(/�+$/, "") + "\n[stat tail truncated at the cap]";
23163
+ }
23164
+ body = notice + kept;
23165
+ }
23166
+ if (dirty) {
23167
+ const delta = await git(["diff", "--stat", "HEAD"], root);
23168
+ body += `
23169
+
23170
+ Uncommitted delta at end (stat only \u2014 not part of the attested range):
23171
+ ${delta.code === 0 && delta.stdout.trim() ? delta.stdout : "(unreadable or empty)"}`;
23172
+ }
23173
+ return { body: header + body, commitCount };
23174
+ }
23175
+ async function postAttestedArtifact(deps2, sessionId, input) {
23176
+ const credentials = deps2.resolveTarget();
23177
+ try {
23178
+ const response = await (deps2.fetchImpl ?? fetch)(
23179
+ new URL(
23180
+ `/api/agent-sessions/${sessionId}/artifacts`,
23181
+ stacksBaseUrlOf(credentials.url)
23182
+ ),
23183
+ {
23184
+ method: "POST",
23185
+ headers: {
23186
+ authorization: `Bearer ${credentials.token}`,
23187
+ "content-type": "application/json"
23188
+ },
23189
+ body: JSON.stringify({
23190
+ kind: input.kind,
23191
+ title: input.title,
23192
+ body: redactLocal(input.body, deps2.env),
23193
+ attested: true
23194
+ })
23195
+ }
23196
+ );
23197
+ const payload = await response.json().catch(() => ({}));
23198
+ return response.ok ? { ok: true, artifactId: payload.artifactId ?? null, detail: null } : {
23199
+ ok: false,
23200
+ artifactId: null,
23201
+ detail: payload.detail ?? payload.error ?? `HTTP ${response.status}`
23202
+ };
23203
+ } catch (error2) {
23204
+ return {
23205
+ ok: false,
23206
+ artifactId: null,
23207
+ detail: error2 instanceof Error ? error2.message : "network failure"
23208
+ };
23209
+ }
23210
+ }
23105
23211
  async function runSessionEnd(sessionIdFlag, flags, deps2) {
23106
23212
  try {
23107
23213
  const inspection = await inspectRepository(deps2.cwd(), deps2.git);
@@ -23168,6 +23274,39 @@ async function runSessionEnd(sessionIdFlag, flags, deps2) {
23168
23274
  );
23169
23275
  }
23170
23276
  const sessionId = resolved;
23277
+ let commitCount = null;
23278
+ try {
23279
+ const row = await call(caller, "get_agent_session", { sessionId });
23280
+ const startHead = typeof row.startHead === "string" ? row.startHead : null;
23281
+ const attestRepo = inspection && inspection.repoOwnerName === row.repoOwnerName ? inspection : null;
23282
+ if (attestRepo?.head && startHead && attestRepo.head !== startHead && (row.status === "ACTIVE" || row.status === "STARTING")) {
23283
+ const built = await buildAttestedDiffBody(
23284
+ deps2.git ?? defaultGitRunner,
23285
+ attestRepo.root,
23286
+ startHead,
23287
+ attestRepo.head,
23288
+ attestRepo.dirty
23289
+ );
23290
+ if (built) {
23291
+ commitCount = built.commitCount;
23292
+ const pushed = await postAttestedArtifact(deps2, sessionId, {
23293
+ kind: "diff",
23294
+ title: `Attested diff ${startHead.slice(0, 10)}..${attestRepo.head.slice(0, 10)} (${built.commitCount} commit${built.commitCount === 1 ? "" : "s"})`,
23295
+ body: built.body
23296
+ });
23297
+ if (pushed.ok) {
23298
+ deps2.writeOut(
23299
+ `Attested diff pushed \u2192 artifact ${pushed.artifactId ?? "(unknown)"} (${built.commitCount} commit(s), source: cli)`
23300
+ );
23301
+ } else {
23302
+ deps2.writeErr(
23303
+ `note: attested diff push failed (${pushed.detail}) \u2014 the E1 delivery check will refuse the close; retry \`jentrix session end\`, or acknowledge with --acknowledge-evidence-gaps.`
23304
+ );
23305
+ }
23306
+ }
23307
+ }
23308
+ } catch {
23309
+ }
23171
23310
  const hostRan = hasLocalCaptureFootprint(deps2, sessionId);
23172
23311
  const reportTelemetry = (row) => {
23173
23312
  const verdict2 = telemetryVerdict(
@@ -23253,6 +23392,11 @@ async function runSessionEnd(sessionIdFlag, flags, deps2) {
23253
23392
  endHead: sameRepo?.head ?? null,
23254
23393
  endDirty: sameRepo?.dirty ?? null,
23255
23394
  ...spooledUsage ? { usage: spooledUsage } : {},
23395
+ // Evidence floor (§5/§6): the CLI-counted commits in the range feed
23396
+ // E2 and the summary's density line; the acknowledge flag closes over
23397
+ // unmet checks with each stamped MISSING into Review readiness.
23398
+ ...commitCount !== null ? { commitCount } : {},
23399
+ ...flags.acknowledgeEvidenceGaps ? { acknowledgeEvidenceGaps: true } : {},
23256
23400
  expectedUpdatedAt: session.updatedAt
23257
23401
  });
23258
23402
  if (inspection) {
@@ -23586,6 +23730,12 @@ function reportError(error2, deps2) {
23586
23730
  }
23587
23731
  const message = error2 instanceof Error ? error2.message : String(error2);
23588
23732
  deps2.writeErr(`error: ${message}`);
23733
+ if (message.includes("EVIDENCE_FLOOR")) {
23734
+ deps2.writeErr(
23735
+ "To close anyway with the unmet checks stamped MISSING: `jentrix session end --acknowledge-evidence-gaps` (a declared deviation is an ordinary `jentrix push gap`)."
23736
+ );
23737
+ return EXIT_CODES.CONFLICT;
23738
+ }
23589
23739
  if (message.includes("SESSION_ALREADY_BOUND")) return EXIT_CODES.CONFLICT;
23590
23740
  if (message.includes("FORBIDDEN") || message.includes("PROJECT_DISCOVERY_REQUIRES_UNPINNED_LOGIN")) {
23591
23741
  return EXIT_CODES.FORBIDDEN;
@@ -23641,7 +23791,12 @@ function registerSessionCommand(program3, deps2, onExit2) {
23641
23791
  ).option("--json", "stable JSON output").action(
23642
23792
  async (sessionId, flags) => onExit2(await runSessionStatus(sessionId, flags, deps2))
23643
23793
  );
23644
- session.command("end [sessionId]").description("Close a session, verify capture, and store the RUN_SUMMARY.").option("--json", "stable JSON output").action(
23794
+ session.command("end [sessionId]").description(
23795
+ "Close a session, verify capture, and store the RUN_SUMMARY. Pushes the attested delivery patch when HEAD moved, and enforces the evidence-floor checks E1\u2013E4 (refusal names each unmet check and its fix)."
23796
+ ).option("--json", "stable JSON output").option(
23797
+ "--acknowledge-evidence-gaps",
23798
+ "close even with unmet evidence-floor checks \u2014 each unmet check is stamped MISSING into the summary's Review readiness"
23799
+ ).action(
23645
23800
  async (sessionId, flags) => onExit2(await runSessionEnd(sessionId, flags, deps2))
23646
23801
  );
23647
23802
  return session;
@@ -23890,10 +24045,11 @@ function sessionBindingCheck(detected) {
23890
24045
  function detectProvider(flags, deps2) {
23891
24046
  if (flags.provider === "codex") {
23892
24047
  const hook2 = flags.providerSession ? null : readCodexHookContext(deps2);
24048
+ const providerSessionId = flags.providerSession ?? hook2?.sessionId ?? null;
23893
24049
  return {
23894
24050
  provider: "codex",
23895
- providerSessionId: flags.providerSession ?? hook2?.sessionId ?? null,
23896
- transcriptPath: flags.transcriptPath ?? hook2?.transcriptPath ?? null,
24051
+ providerSessionId,
24052
+ transcriptPath: flags.transcriptPath ?? hook2?.transcriptPath ?? (providerSessionId ? readCodexRolloutPath(deps2, providerSessionId) : null),
23897
24053
  basis: flags.providerSession ? `codex thread ${flags.providerSession} (explicit)` : hook2?.basis ?? null,
23898
24054
  newerElsewhere: null,
23899
24055
  transcriptGap: null,
@@ -24276,9 +24432,13 @@ ${JSON.stringify(session.alignment, null, 2)}`
24276
24432
  } : {},
24277
24433
  ...budget !== null ? { tokenBudget: budget } : {},
24278
24434
  capture: captureMode,
24435
+ // Evidence floor (D3): only an explicit --no-skeleton is sent; absent
24436
+ // lets the server keep the stored mode (default "on").
24437
+ ...flags.skeleton === false ? { skeleton: "off" } : {},
24279
24438
  expectedUpdatedAt: session.updatedAt
24280
24439
  });
24281
24440
  const alignment = aligned.alignment;
24441
+ const skeletonMode = alignment.skeleton === "off" ? "off" : "on";
24282
24442
  writeAlignmentMarker(
24283
24443
  deps2.configPath,
24284
24444
  inspection.root,
@@ -24326,8 +24486,9 @@ ${JSON.stringify(session.alignment, null, 2)}`
24326
24486
  mcpUrl: target.url,
24327
24487
  bearer: deps2.resolveTarget().token,
24328
24488
  // …and the config file itself, so the host FOLLOWS later rotations
24329
- // instead of dying with a revoked snapshot (2026-08-08 finding).
24330
- configPath: deps2.configPath,
24489
+ // instead of dying with a revoked snapshot (2026-08-08 finding)
24490
+ // but ONLY when the token IS the config's (hostConfigPathOf).
24491
+ configPath: hostConfigPathOf(deps2),
24331
24492
  repoRoot: inspection.root,
24332
24493
  installationId,
24333
24494
  mode: "watch",
@@ -24335,6 +24496,7 @@ ${JSON.stringify(session.alignment, null, 2)}`
24335
24496
  ...detected.transcriptPath ? { transcriptPath: detected.transcriptPath } : {},
24336
24497
  ...detected.hookDir ? { hookDir: detected.hookDir } : {},
24337
24498
  captureTrace: captureMode === "on",
24499
+ collectSkeleton: skeletonMode === "on",
24338
24500
  spoolRoot: deps2.spoolRoot
24339
24501
  });
24340
24502
  if (pid !== null) {
@@ -24809,6 +24971,7 @@ async function runAlign(flags, deps2) {
24809
24971
  ` Agent: ${a.agent?.provider ?? "\u2014"}`,
24810
24972
  ` Repo: ${a.repo?.ownerName ?? "\u2014"}@${a.repo?.branch ?? "detached"}`,
24811
24973
  ` Capture: ${a.capture ?? "off"}`,
24974
+ ` Skeleton: ${a.skeleton ?? "on"} (content-free activity counts/timing; --no-skeleton opts out)`,
24812
24975
  `Alignment snapshot (server-confirmed): ${JSON.stringify(result.alignment)}`
24813
24976
  ].join("\n")
24814
24977
  );
@@ -24873,6 +25036,9 @@ function registerAlignCommand(program3, deps2, onExit2) {
24873
25036
  ).option(
24874
25037
  "--no-capture",
24875
25038
  "keep TRACE capture OFF explicitly \u2014 also corrects a wrongly-on record on re-align"
25039
+ ).option(
25040
+ "--no-skeleton",
25041
+ "opt out of the content-free activity skeleton (event/tool counts, files-touched, timing buckets) for this session \u2014 default on, independent of capture"
24876
25042
  ).addOption(
24877
25043
  new Option(
24878
25044
  "--provider <provider>",
@@ -24910,7 +25076,8 @@ var PUSH_KINDS = [
24910
25076
  "prd",
24911
25077
  "gap",
24912
25078
  "issue",
24913
- "context"
25079
+ "context",
25080
+ "log"
24914
25081
  ];
24915
25082
  var ARTIFACT_TYPE_BY_PUSH_KIND = {
24916
25083
  plan: "PLAN",
@@ -24925,8 +25092,23 @@ var ARTIFACT_TYPE_BY_PUSH_KIND = {
24925
25092
  prd: "PRD",
24926
25093
  gap: "GAP",
24927
25094
  issue: "ISSUE",
24928
- context: "SOURCE_DIGEST"
25095
+ context: "SOURCE_DIGEST",
25096
+ log: "LOG"
24929
25097
  };
25098
+ var MAX_FROM_CMD_OUTPUT_BYTES = 64 * 1024;
25099
+ function fromCmdLogBody(command, exitCode, output) {
25100
+ const header = `$ ${command}
25101
+ exit code: ${exitCode}
25102
+
25103
+ `;
25104
+ if (Buffer.byteLength(output, "utf8") <= MAX_FROM_CMD_OUTPUT_BYTES) {
25105
+ return header + (output || "(no output)");
25106
+ }
25107
+ const notice = `[head truncated: output exceeded ${MAX_FROM_CMD_OUTPUT_BYTES / 1024} KB \u2014 tail retained]
25108
+ `;
25109
+ const tail = Buffer.from(output, "utf8").subarray(-MAX_FROM_CMD_OUTPUT_BYTES).toString("utf8").replace(/^�+/, "");
25110
+ return header + notice + tail;
25111
+ }
24930
25112
  function sessionTarget(sessionId) {
24931
25113
  return {
24932
25114
  path: `/api/agent-sessions/${sessionId}/artifacts`,
@@ -24936,6 +25118,32 @@ function sessionTarget(sessionId) {
24936
25118
  function taskTarget(taskId) {
24937
25119
  return { path: `/api/tasks/${taskId}/artifacts`, label: `task ${taskId}` };
24938
25120
  }
25121
+ function defaultRunCommand(command) {
25122
+ return new Promise((resolve5) => {
25123
+ const child = spawn2(command, { shell: true, windowsHide: true });
25124
+ let tail = Buffer.alloc(0);
25125
+ const keep = (chunk) => {
25126
+ tail = Buffer.concat([tail, chunk]);
25127
+ if (tail.byteLength > MAX_FROM_CMD_OUTPUT_BYTES * 2) {
25128
+ tail = tail.subarray(-MAX_FROM_CMD_OUTPUT_BYTES);
25129
+ }
25130
+ };
25131
+ child.stdout?.on("data", keep);
25132
+ child.stderr?.on("data", keep);
25133
+ child.once(
25134
+ "error",
25135
+ (error2) => resolve5({ code: 127, output: `${error2.message}
25136
+ ` })
25137
+ );
25138
+ child.once(
25139
+ "exit",
25140
+ (code, signal) => resolve5({
25141
+ code: code ?? (signal ? 130 : 1),
25142
+ output: tail.toString("utf8")
25143
+ })
25144
+ );
25145
+ });
25146
+ }
24939
25147
  async function drainStdin() {
24940
25148
  const chunks = [];
24941
25149
  for await (const chunk of process.stdin) {
@@ -24955,6 +25163,21 @@ async function runPush(kind, file, flags, deps2) {
24955
25163
  "--basis records what a decision rested on \u2014 it only applies to `jentrix push decision`"
24956
25164
  );
24957
25165
  }
25166
+ if (flags.fromCmd !== void 0) {
25167
+ if (kind !== "log") {
25168
+ throw new UsageError(
25169
+ '--from-cmd captures a command\'s exit code and output as a LOG \u2014 it only applies to `jentrix push log --from-cmd "<command>"`'
25170
+ );
25171
+ }
25172
+ if (file || flags.ref) {
25173
+ throw new UsageError(
25174
+ "--from-cmd generates the LOG body from the command run \u2014 drop the file/--ref"
25175
+ );
25176
+ }
25177
+ if (!flags.fromCmd.trim()) {
25178
+ throw new UsageError("--from-cmd needs a non-empty command");
25179
+ }
25180
+ }
24958
25181
  if (flags.ref) {
24959
25182
  if (flags.basis?.length) {
24960
25183
  throw new UsageError(
@@ -25007,7 +25230,17 @@ async function runPush(kind, file, flags, deps2) {
25007
25230
  correlatedSessionId = marker.sessionId;
25008
25231
  fallbackTaskId = marker.taskId;
25009
25232
  }
25010
- const raw = file ? readFileSync4(file, "utf8") : await (deps2.readStdin ?? drainStdin)();
25233
+ let commandExit = null;
25234
+ let raw;
25235
+ if (flags.fromCmd !== void 0) {
25236
+ const command = flags.fromCmd.trim();
25237
+ deps2.writeErr(`running: ${command}`);
25238
+ const ran = await (deps2.runCommand ?? defaultRunCommand)(command);
25239
+ commandExit = ran.code;
25240
+ raw = fromCmdLogBody(command, ran.code, ran.output);
25241
+ } else {
25242
+ raw = file ? readFileSync4(file, "utf8") : await (deps2.readStdin ?? drainStdin)();
25243
+ }
25011
25244
  if (!raw.trim()) {
25012
25245
  throw new UsageError(
25013
25246
  file ? `${file} is empty` : "stdin is empty \u2014 pipe the content or name a file"
@@ -25031,6 +25264,9 @@ ${raw}` : raw;
25031
25264
  ...flags.title ? { title: flags.title } : {},
25032
25265
  ...flags.agent ? { producerLabel: flags.agent } : {},
25033
25266
  ...flags.agentEmoji ? { producerEmoji: flags.agentEmoji } : {},
25267
+ // D4: only the CLI-generated capture is attested — never a piped
25268
+ // body, whatever its kind.
25269
+ ...commandExit !== null ? { attested: true } : {},
25034
25270
  body
25035
25271
  })
25036
25272
  }
@@ -25086,7 +25322,12 @@ ${raw}` : raw;
25086
25322
  if (isMintableKind(kind) && payload.taskId) {
25087
25323
  await offerCardMint(kind, payload, flags, deps2, correlatedSessionId);
25088
25324
  }
25089
- return 0;
25325
+ if (commandExit !== null && commandExit !== 0) {
25326
+ deps2.writeErr(
25327
+ `command exited ${commandExit} \u2014 recorded in the LOG; exiting with the command's code`
25328
+ );
25329
+ }
25330
+ return commandExit ?? 0;
25090
25331
  } catch (error2) {
25091
25332
  if (error2 instanceof UsageError) {
25092
25333
  deps2.writeErr(`error: ${error2.message}`);
@@ -25400,6 +25641,9 @@ function registerPushCommand(program3, deps2, onExit2) {
25400
25641
  ).option(
25401
25642
  "--blocks",
25402
25643
  "with --yes on a findings/gap/issue push: the minted card BLOCKS the aligned task (acceptance-blocking) instead of RELATES_TO"
25644
+ ).option(
25645
+ "--from-cmd <command>",
25646
+ `log pushes only: run the command locally, capture exit code + a 64 KB tail-biased output tail, and push an ATTESTED LOG opening with both \u2014 then exit with the command's own code ("tests green" claims carry evidence)`
25403
25647
  ).option("--json", "stable JSON output").action(
25404
25648
  async (kind, file, flags) => onExit2(await runPush(kind, file, flags, deps2))
25405
25649
  );
@@ -27754,7 +27998,7 @@ function registerPluginCommand(program3, deps2, onExit2) {
27754
27998
  }
27755
27999
 
27756
28000
  // src/commands/runner.ts
27757
- import { execFile as execFile2, spawn as spawn2 } from "node:child_process";
28001
+ import { execFile as execFile2, spawn as spawn3 } from "node:child_process";
27758
28002
  import { constants, promises as fs } from "node:fs";
27759
28003
  import { homedir } from "node:os";
27760
28004
  import { delimiter, isAbsolute, resolve as resolve3 } from "node:path";
@@ -28188,7 +28432,7 @@ function runRunnerForeground(file, args) {
28188
28432
  }
28189
28433
  const target = execTarget(file, args);
28190
28434
  return new Promise((resolveRun, reject) => {
28191
- const child = spawn2(target.file, target.args, {
28435
+ const child = spawn3(target.file, target.args, {
28192
28436
  stdio: "inherit",
28193
28437
  ...target.options
28194
28438
  });
@@ -29202,7 +29446,11 @@ var sessionDeps = {
29202
29446
  configPath: CONFIG_PATH,
29203
29447
  resolveTarget: () => {
29204
29448
  const target = resolveConfig({ env: process.env, file: configFile() });
29205
- return { token: target.token, url: target.url };
29449
+ return {
29450
+ token: target.token,
29451
+ url: target.url,
29452
+ tokenSource: target.tokenSource
29453
+ };
29206
29454
  },
29207
29455
  ensureInstallationId: () => ensureInstallationId(HOME_CONFIG_PATH),
29208
29456
  connect: async (target) => {
@@ -29225,7 +29473,7 @@ var sessionDeps = {
29225
29473
  planPath
29226
29474
  ]);
29227
29475
  try {
29228
- const child = spawn3(target.file, target.args, {
29476
+ const child = spawn4(target.file, target.args, {
29229
29477
  detached: true,
29230
29478
  stdio: ["ignore", fd, fd],
29231
29479
  ...target.options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jentrix/cli",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Command-line client for the Jentrix MCP surface (jentrix tool <name>, generated noun-verb commands).",
5
5
  "keywords": [
6
6
  "jentrix",
package/surface.json CHANGED
@@ -463,6 +463,14 @@
463
463
  "minLength": 1,
464
464
  "type": "string"
465
465
  },
466
+ "skeleton": {
467
+ "description": "Activity-skeleton mode the CLI declared for this session (--no-skeleton). Default on, independent of capture.",
468
+ "enum": [
469
+ "on",
470
+ "off"
471
+ ],
472
+ "type": "string"
473
+ },
466
474
  "taskId": {
467
475
  "anyOf": [
468
476
  {
@@ -1655,6 +1663,10 @@
1655
1663
  "description": "Close a session with an honest lifecycle outcome (COMPLETED normal end / INTERRUPTED crash / CANCELLED pre-first-turn abort). The server verifies the trace-part manifest against durable artifacts, stores the deterministic RUN_SUMMARY, and sets captureComplete ONLY when every expected part and the summary are acknowledged — lifecycle and capture integrity stay orthogonal. Requires expectedUpdatedAt (CAS).",
1656
1664
  "inputSchema": {
1657
1665
  "properties": {
1666
+ "acknowledgeEvidenceGaps": {
1667
+ "description": "Close even with unmet evidence-floor checks (E1–E3); each unmet check is stamped MISSING into the summary's Review readiness.",
1668
+ "type": "boolean"
1669
+ },
1658
1670
  "captureError": {
1659
1671
  "anyOf": [
1660
1672
  {
@@ -1666,6 +1678,19 @@
1666
1678
  }
1667
1679
  ]
1668
1680
  },
1681
+ "commitCount": {
1682
+ "anyOf": [
1683
+ {
1684
+ "maximum": 9007199254740991,
1685
+ "minimum": 0,
1686
+ "type": "integer"
1687
+ },
1688
+ {
1689
+ "type": "null"
1690
+ }
1691
+ ],
1692
+ "description": "CLI-counted commits in startHead..endHead; absent = not counted (HEAD movement is then the only signal)."
1693
+ },
1669
1694
  "endBranch": {
1670
1695
  "anyOf": [
1671
1696
  {