@jentrix/cli 0.5.20 → 0.5.22

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,6 +1,6 @@
1
1
  {
2
2
  "name": "jentrix",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
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",
@@ -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`.
@@ -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.
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.20";
20969
+ var CLI_VERSION = "0.5.22";
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
@@ -21387,6 +21388,9 @@ function buildAlignDisclosures(catalog) {
21387
21388
  disclosures.push(
21388
21389
  catalog.captureMode === "on" ? "TRACE capture: ON \u2014 the full transcript will be recorded" : "TRACE capture: OFF (MVP default) \u2014 typed artifacts only, no transcript"
21389
21390
  );
21391
+ disclosures.push(
21392
+ catalog.skeletonMode === "on" ? "activity skeleton: ON (default) \u2014 content-free counts/timing (turns, tool names, files-touched), independent of capture; --no-skeleton opts out" : "activity skeleton: OFF \u2014 no counts, no timing, no files-touched; the session's Session activity section will read as not observed"
21393
+ );
21390
21394
  disclosures.push("write the local alignment marker for this checkout");
21391
21395
  const mcp = catalog.mcpConfig;
21392
21396
  if (mcp && mcp.action !== "unchanged") {
@@ -22449,20 +22453,39 @@ function readLiveHostMarker(deps2, sessionId) {
22449
22453
  return null;
22450
22454
  }
22451
22455
  }
22452
- async function waitForHostExit(deps2, sessionId, pid, timeoutMs) {
22456
+ async function waitForHostEnd(deps2, sessionId, pid, timeoutMs) {
22453
22457
  const sleep = deps2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
22454
- const path = join2(deps2.spoolRoot, sessionId, "host.json");
22458
+ const dir = join2(deps2.spoolRoot, sessionId);
22459
+ const path = join2(dir, "host.json");
22455
22460
  const deadline = Date.now() + timeoutMs;
22456
22461
  while (Date.now() < deadline) {
22457
22462
  await sleep(500);
22463
+ const refusal = readEndRefusal(deps2, sessionId);
22464
+ if (refusal) return { kind: "refused", message: refusal };
22458
22465
  try {
22459
22466
  const marker = JSON.parse(readFileSync2(path, "utf8"));
22460
- if (marker.exitedAt) return true;
22467
+ if (marker.exitedAt) return { kind: "exited" };
22461
22468
  } catch {
22462
22469
  }
22463
- if (!(deps2.isPidAlive ?? defaultIsPidAlive)(pid)) return false;
22470
+ if (!(deps2.isPidAlive ?? defaultIsPidAlive)(pid)) return { kind: "gone" };
22471
+ }
22472
+ return { kind: "gone" };
22473
+ }
22474
+ function readEndRefusal(deps2, sessionId) {
22475
+ try {
22476
+ const marker = JSON.parse(
22477
+ readFileSync2(join2(deps2.spoolRoot, sessionId, "end-refusal.json"), "utf8")
22478
+ );
22479
+ return typeof marker.message === "string" && marker.message.trim() ? marker.message : null;
22480
+ } catch {
22481
+ return null;
22482
+ }
22483
+ }
22484
+ function clearEndRefusal(deps2, sessionId) {
22485
+ try {
22486
+ unlinkSync(join2(deps2.spoolRoot, sessionId, "end-refusal.json"));
22487
+ } catch {
22464
22488
  }
22465
- return false;
22466
22489
  }
22467
22490
  function localCaptureLines(deps2, sessionId, serverStatus) {
22468
22491
  const dir = join2(deps2.spoolRoot, sessionId);
@@ -22584,8 +22607,9 @@ async function runSessionStart(provider, flags, deps2) {
22584
22607
  // outlives the CLI holding a dead credential.
22585
22608
  bearer: deps2.resolveTarget().token,
22586
22609
  // …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,
22610
+ // instead of dying with a revoked snapshot (2026-08-08 finding)
22611
+ // but ONLY when the token IS the config's (hostConfigPathOf).
22612
+ configPath: hostConfigPathOf(deps2),
22589
22613
  repoRoot: inspection.root,
22590
22614
  installationId,
22591
22615
  mode: "launch",
@@ -22944,8 +22968,9 @@ async function runSessionAttach(flags, deps2) {
22944
22968
  // outlives the CLI holding a dead credential.
22945
22969
  bearer: deps2.resolveTarget().token,
22946
22970
  // …and the config file itself, so the host FOLLOWS later rotations
22947
- // instead of dying with a revoked snapshot (2026-08-08 finding).
22948
- configPath: deps2.configPath,
22971
+ // instead of dying with a revoked snapshot (2026-08-08 finding)
22972
+ // but ONLY when the token IS the config's (hostConfigPathOf).
22973
+ configPath: hostConfigPathOf(deps2),
22949
22974
  repoRoot: inspection.root,
22950
22975
  installationId,
22951
22976
  mode: "watch",
@@ -23026,6 +23051,8 @@ async function runSessionStatus(sessionId, flags, deps2) {
23026
23051
  `Project: ${String(session.projectName)} (${String(session.projectId)})`,
23027
23052
  `Repository: ${String(session.repoOwnerName)}`,
23028
23053
  `Capture: ${capture}`,
23054
+ // Evidence floor (D3): the skeleton mode the alignment declared.
23055
+ `Skeleton: ${session.alignment?.skeleton === "off" ? "off (--no-skeleton at align)" : "on (content-free activity counts/timing)"}`,
23029
23056
  ...local,
23030
23057
  telemetry.line,
23031
23058
  `Summary artifact: ${session.summaryArtifactId ? String(session.summaryArtifactId) : "\u2014"}`
@@ -23122,6 +23149,85 @@ function telemetryVerdict(sessionId, usage, hostRan, provider = "claude") {
23122
23149
  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.`
23123
23150
  };
23124
23151
  }
23152
+ function hostConfigPathOf(deps2) {
23153
+ const source = deps2.resolveTarget().tokenSource;
23154
+ return source === void 0 || source === "file" ? deps2.configPath : null;
23155
+ }
23156
+ var MAX_ATTESTED_DIFF_BYTES = 256 * 1024;
23157
+ async function buildAttestedDiffBody(git, root, startHead, endHead, dirty) {
23158
+ const counted = await git(
23159
+ ["rev-list", "--count", `${startHead}..${endHead}`],
23160
+ root
23161
+ );
23162
+ if (counted.code !== 0) return null;
23163
+ const commitCount = Number(counted.stdout.trim());
23164
+ if (!Number.isFinite(commitCount) || commitCount === 0) return null;
23165
+ const header = [
23166
+ "Attested delivery \u2014 generated by `jentrix session end` from the aligned checkout (source: cli).",
23167
+ `Range: ${startHead}..${endHead} \xB7 ${commitCount} commit(s)`,
23168
+ ""
23169
+ ].join("\n");
23170
+ const cap = MAX_ATTESTED_DIFF_BYTES - Buffer.byteLength(header, "utf8");
23171
+ const full = await git(
23172
+ ["log", "--patch", "--stat", `${startHead}..${endHead}`],
23173
+ root
23174
+ );
23175
+ let body;
23176
+ if (full.code === 0 && Buffer.byteLength(full.stdout, "utf8") <= cap) {
23177
+ body = full.stdout;
23178
+ } else {
23179
+ const stat = await git(["log", "--stat", `${startHead}..${endHead}`], root);
23180
+ 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";
23181
+ let kept = stat.code === 0 ? stat.stdout : "(git log --stat failed)";
23182
+ const room = cap - Buffer.byteLength(notice, "utf8");
23183
+ if (Buffer.byteLength(kept, "utf8") > room) {
23184
+ kept = Buffer.from(kept, "utf8").subarray(0, Math.max(0, room - 64)).toString("utf8").replace(/�+$/, "") + "\n[stat tail truncated at the cap]";
23185
+ }
23186
+ body = notice + kept;
23187
+ }
23188
+ let uncommitted = null;
23189
+ if (dirty) {
23190
+ const delta = await git(["diff", "--stat", "HEAD"], root);
23191
+ uncommitted = delta.code === 0 && delta.stdout.trim() ? delta.stdout.trim() : null;
23192
+ }
23193
+ return { body: header + body, commitCount, uncommitted };
23194
+ }
23195
+ async function postAttestedArtifact(deps2, sessionId, input) {
23196
+ const credentials = deps2.resolveTarget();
23197
+ try {
23198
+ const response = await (deps2.fetchImpl ?? fetch)(
23199
+ new URL(
23200
+ `/api/agent-sessions/${sessionId}/artifacts`,
23201
+ stacksBaseUrlOf(credentials.url)
23202
+ ),
23203
+ {
23204
+ method: "POST",
23205
+ headers: {
23206
+ authorization: `Bearer ${credentials.token}`,
23207
+ "content-type": "application/json"
23208
+ },
23209
+ body: JSON.stringify({
23210
+ kind: input.kind,
23211
+ title: input.title,
23212
+ body: redactLocal(input.body, deps2.env),
23213
+ attested: true
23214
+ })
23215
+ }
23216
+ );
23217
+ const payload = await response.json().catch(() => ({}));
23218
+ return response.ok ? { ok: true, artifactId: payload.artifactId ?? null, detail: null } : {
23219
+ ok: false,
23220
+ artifactId: null,
23221
+ detail: payload.detail ?? payload.error ?? `HTTP ${response.status}`
23222
+ };
23223
+ } catch (error2) {
23224
+ return {
23225
+ ok: false,
23226
+ artifactId: null,
23227
+ detail: error2 instanceof Error ? error2.message : "network failure"
23228
+ };
23229
+ }
23230
+ }
23125
23231
  async function runSessionEnd(sessionIdFlag, flags, deps2) {
23126
23232
  try {
23127
23233
  const inspection = await inspectRepository(deps2.cwd(), deps2.git);
@@ -23188,6 +23294,45 @@ async function runSessionEnd(sessionIdFlag, flags, deps2) {
23188
23294
  );
23189
23295
  }
23190
23296
  const sessionId = resolved;
23297
+ let commitCount = null;
23298
+ try {
23299
+ const row = await call(caller, "get_agent_session", { sessionId });
23300
+ const startHead = typeof row.startHead === "string" ? row.startHead : null;
23301
+ const attestRepo = inspection && inspection.repoOwnerName === row.repoOwnerName ? inspection : null;
23302
+ if (attestRepo?.head && startHead && attestRepo.head !== startHead && (row.status === "ACTIVE" || row.status === "STARTING")) {
23303
+ const built = await buildAttestedDiffBody(
23304
+ deps2.git ?? defaultGitRunner,
23305
+ attestRepo.root,
23306
+ startHead,
23307
+ attestRepo.head,
23308
+ attestRepo.dirty
23309
+ );
23310
+ if (built) {
23311
+ commitCount = built.commitCount;
23312
+ const pushed = await postAttestedArtifact(deps2, sessionId, {
23313
+ kind: "diff",
23314
+ title: `Attested diff ${startHead.slice(0, 10)}..${attestRepo.head.slice(0, 10)} (${built.commitCount} commit${built.commitCount === 1 ? "" : "s"})`,
23315
+ body: built.body
23316
+ });
23317
+ if (pushed.ok) {
23318
+ deps2.writeOut(
23319
+ `Attested diff pushed \u2192 artifact ${pushed.artifactId ?? "(unknown)"} (${built.commitCount} commit(s), source: cli)`
23320
+ );
23321
+ if (built.uncommitted) {
23322
+ deps2.writeOut(
23323
+ `note: uncommitted delta at end (stat only \u2014 not part of the attested range):
23324
+ ${built.uncommitted}`
23325
+ );
23326
+ }
23327
+ } else {
23328
+ deps2.writeErr(
23329
+ `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.`
23330
+ );
23331
+ }
23332
+ }
23333
+ }
23334
+ } catch {
23335
+ }
23191
23336
  const hostRan = hasLocalCaptureFootprint(deps2, sessionId);
23192
23337
  const reportTelemetry = (row) => {
23193
23338
  const verdict2 = telemetryVerdict(
@@ -23201,21 +23346,32 @@ async function runSessionEnd(sessionIdFlag, flags, deps2) {
23201
23346
  };
23202
23347
  const live = readLiveHostMarker(deps2, sessionId);
23203
23348
  if (live) {
23349
+ clearEndRefusal(deps2, sessionId);
23204
23350
  writeFileSync2(
23205
23351
  join2(deps2.spoolRoot, sessionId, "end-request.json"),
23206
- JSON.stringify({ requestedAt: (/* @__PURE__ */ new Date()).toISOString() }),
23352
+ JSON.stringify({
23353
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
23354
+ // JEN-167: the host closes the session, so the operator's
23355
+ // acknowledgement has to travel with the request — otherwise the
23356
+ // floor refuses a close they explicitly asked to force. The
23357
+ // CLI-counted commits ride along for the same reason: only the
23358
+ // CLI can count them, and the host's close is the one that lands.
23359
+ ...flags.acknowledgeEvidenceGaps ? { acknowledgeEvidenceGaps: true } : {},
23360
+ ...commitCount !== null ? { commitCount } : {}
23361
+ }),
23207
23362
  { mode: 384 }
23208
23363
  );
23209
23364
  deps2.writeOut(
23210
23365
  `Local capture host (pid ${live.pid}) is finalizing capture\u2026`
23211
23366
  );
23212
- const finished = await waitForHostExit(
23213
- deps2,
23214
- sessionId,
23215
- live.pid,
23216
- 9e4
23217
- );
23218
- if (finished) {
23367
+ const outcome = await waitForHostEnd(deps2, sessionId, live.pid, 9e4);
23368
+ if (outcome.kind === "refused") {
23369
+ deps2.writeErr(
23370
+ `local capture host (pid ${live.pid}) is still running \u2014 the skeleton, heartbeats and telemetry keep accumulating through the comply work; re-run \`jentrix session end ${sessionId}\` once the evidence is pushed`
23371
+ );
23372
+ throw new Error(outcome.message);
23373
+ }
23374
+ if (outcome.kind === "exited") {
23219
23375
  const final = await call(caller, "get_agent_session", { sessionId });
23220
23376
  const stillOpen = final.status === "STARTING" || final.status === "ACTIVE";
23221
23377
  if (!stillOpen) {
@@ -23273,6 +23429,11 @@ async function runSessionEnd(sessionIdFlag, flags, deps2) {
23273
23429
  endHead: sameRepo?.head ?? null,
23274
23430
  endDirty: sameRepo?.dirty ?? null,
23275
23431
  ...spooledUsage ? { usage: spooledUsage } : {},
23432
+ // Evidence floor (§5/§6): the CLI-counted commits in the range feed
23433
+ // E2 and the summary's density line; the acknowledge flag closes over
23434
+ // unmet checks with each stamped MISSING into Review readiness.
23435
+ ...commitCount !== null ? { commitCount } : {},
23436
+ ...flags.acknowledgeEvidenceGaps ? { acknowledgeEvidenceGaps: true } : {},
23276
23437
  expectedUpdatedAt: session.updatedAt
23277
23438
  });
23278
23439
  if (inspection) {
@@ -23606,6 +23767,12 @@ function reportError(error2, deps2) {
23606
23767
  }
23607
23768
  const message = error2 instanceof Error ? error2.message : String(error2);
23608
23769
  deps2.writeErr(`error: ${message}`);
23770
+ if (message.includes("EVIDENCE_FLOOR")) {
23771
+ deps2.writeErr(
23772
+ "To close anyway with the unmet checks stamped MISSING: `jentrix session end --acknowledge-evidence-gaps` (a declared deviation is an ordinary `jentrix push gap`)."
23773
+ );
23774
+ return EXIT_CODES.CONFLICT;
23775
+ }
23609
23776
  if (message.includes("SESSION_ALREADY_BOUND")) return EXIT_CODES.CONFLICT;
23610
23777
  if (message.includes("FORBIDDEN") || message.includes("PROJECT_DISCOVERY_REQUIRES_UNPINNED_LOGIN")) {
23611
23778
  return EXIT_CODES.FORBIDDEN;
@@ -23661,7 +23828,12 @@ function registerSessionCommand(program3, deps2, onExit2) {
23661
23828
  ).option("--json", "stable JSON output").action(
23662
23829
  async (sessionId, flags) => onExit2(await runSessionStatus(sessionId, flags, deps2))
23663
23830
  );
23664
- session.command("end [sessionId]").description("Close a session, verify capture, and store the RUN_SUMMARY.").option("--json", "stable JSON output").action(
23831
+ session.command("end [sessionId]").description(
23832
+ "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)."
23833
+ ).option("--json", "stable JSON output").option(
23834
+ "--acknowledge-evidence-gaps",
23835
+ "close even with unmet evidence-floor checks \u2014 each unmet check is stamped MISSING into the summary's Review readiness"
23836
+ ).action(
23665
23837
  async (sessionId, flags) => onExit2(await runSessionEnd(sessionId, flags, deps2))
23666
23838
  );
23667
23839
  return session;
@@ -24050,6 +24222,9 @@ async function buildCatalog(caller, flags, detection) {
24050
24222
  // checkout root); defaults here are the no-live-host reading.
24051
24223
  liveHostPid: null,
24052
24224
  captureMode: decideCaptureMode(flags.capture, false),
24225
+ // JEN-169: only `--no-skeleton` acts (the same rule submit applies at
24226
+ // line ~1273), so the disclosure needs no server round trip.
24227
+ skeletonMode: flags.skeleton === false ? "off" : "on",
24053
24228
  producer: producerFromFlags(flags),
24054
24229
  tokenBudget: budgetFromFlags(flags),
24055
24230
  preconditionNotices: [],
@@ -24297,9 +24472,13 @@ ${JSON.stringify(session.alignment, null, 2)}`
24297
24472
  } : {},
24298
24473
  ...budget !== null ? { tokenBudget: budget } : {},
24299
24474
  capture: captureMode,
24475
+ // Evidence floor (D3): only an explicit --no-skeleton is sent; absent
24476
+ // lets the server keep the stored mode (default "on").
24477
+ ...flags.skeleton === false ? { skeleton: "off" } : {},
24300
24478
  expectedUpdatedAt: session.updatedAt
24301
24479
  });
24302
24480
  const alignment = aligned.alignment;
24481
+ const skeletonMode = alignment.skeleton === "off" ? "off" : "on";
24303
24482
  writeAlignmentMarker(
24304
24483
  deps2.configPath,
24305
24484
  inspection.root,
@@ -24347,8 +24526,9 @@ ${JSON.stringify(session.alignment, null, 2)}`
24347
24526
  mcpUrl: target.url,
24348
24527
  bearer: deps2.resolveTarget().token,
24349
24528
  // …and the config file itself, so the host FOLLOWS later rotations
24350
- // instead of dying with a revoked snapshot (2026-08-08 finding).
24351
- configPath: deps2.configPath,
24529
+ // instead of dying with a revoked snapshot (2026-08-08 finding)
24530
+ // but ONLY when the token IS the config's (hostConfigPathOf).
24531
+ configPath: hostConfigPathOf(deps2),
24352
24532
  repoRoot: inspection.root,
24353
24533
  installationId,
24354
24534
  mode: "watch",
@@ -24356,6 +24536,7 @@ ${JSON.stringify(session.alignment, null, 2)}`
24356
24536
  ...detected.transcriptPath ? { transcriptPath: detected.transcriptPath } : {},
24357
24537
  ...detected.hookDir ? { hookDir: detected.hookDir } : {},
24358
24538
  captureTrace: captureMode === "on",
24539
+ collectSkeleton: skeletonMode === "on",
24359
24540
  spoolRoot: deps2.spoolRoot
24360
24541
  });
24361
24542
  if (pid !== null) {
@@ -24830,6 +25011,7 @@ async function runAlign(flags, deps2) {
24830
25011
  ` Agent: ${a.agent?.provider ?? "\u2014"}`,
24831
25012
  ` Repo: ${a.repo?.ownerName ?? "\u2014"}@${a.repo?.branch ?? "detached"}`,
24832
25013
  ` Capture: ${a.capture ?? "off"}`,
25014
+ ` Skeleton: ${a.skeleton ?? "on"} (content-free activity counts/timing; --no-skeleton opts out)`,
24833
25015
  `Alignment snapshot (server-confirmed): ${JSON.stringify(result.alignment)}`
24834
25016
  ].join("\n")
24835
25017
  );
@@ -24894,6 +25076,9 @@ function registerAlignCommand(program3, deps2, onExit2) {
24894
25076
  ).option(
24895
25077
  "--no-capture",
24896
25078
  "keep TRACE capture OFF explicitly \u2014 also corrects a wrongly-on record on re-align"
25079
+ ).option(
25080
+ "--no-skeleton",
25081
+ "opt out of the content-free activity skeleton (event/tool counts, files-touched, timing buckets) for this session \u2014 default on, independent of capture"
24897
25082
  ).addOption(
24898
25083
  new Option(
24899
25084
  "--provider <provider>",
@@ -24931,7 +25116,8 @@ var PUSH_KINDS = [
24931
25116
  "prd",
24932
25117
  "gap",
24933
25118
  "issue",
24934
- "context"
25119
+ "context",
25120
+ "log"
24935
25121
  ];
24936
25122
  var ARTIFACT_TYPE_BY_PUSH_KIND = {
24937
25123
  plan: "PLAN",
@@ -24946,8 +25132,23 @@ var ARTIFACT_TYPE_BY_PUSH_KIND = {
24946
25132
  prd: "PRD",
24947
25133
  gap: "GAP",
24948
25134
  issue: "ISSUE",
24949
- context: "SOURCE_DIGEST"
25135
+ context: "SOURCE_DIGEST",
25136
+ log: "LOG"
24950
25137
  };
25138
+ var MAX_FROM_CMD_OUTPUT_BYTES = 64 * 1024;
25139
+ function fromCmdLogBody(command, exitCode, output) {
25140
+ const header = `$ ${command}
25141
+ exit code: ${exitCode}
25142
+
25143
+ `;
25144
+ if (Buffer.byteLength(output, "utf8") <= MAX_FROM_CMD_OUTPUT_BYTES) {
25145
+ return header + (output || "(no output)");
25146
+ }
25147
+ const notice = `[head truncated: output exceeded ${MAX_FROM_CMD_OUTPUT_BYTES / 1024} KB \u2014 tail retained]
25148
+ `;
25149
+ const tail = Buffer.from(output, "utf8").subarray(-MAX_FROM_CMD_OUTPUT_BYTES).toString("utf8").replace(/^�+/, "");
25150
+ return header + notice + tail;
25151
+ }
24951
25152
  function sessionTarget(sessionId) {
24952
25153
  return {
24953
25154
  path: `/api/agent-sessions/${sessionId}/artifacts`,
@@ -24957,6 +25158,32 @@ function sessionTarget(sessionId) {
24957
25158
  function taskTarget(taskId) {
24958
25159
  return { path: `/api/tasks/${taskId}/artifacts`, label: `task ${taskId}` };
24959
25160
  }
25161
+ function defaultRunCommand(command) {
25162
+ return new Promise((resolve5) => {
25163
+ const child = spawn2(command, { shell: true, windowsHide: true });
25164
+ let tail = Buffer.alloc(0);
25165
+ const keep = (chunk) => {
25166
+ tail = Buffer.concat([tail, chunk]);
25167
+ if (tail.byteLength > MAX_FROM_CMD_OUTPUT_BYTES * 2) {
25168
+ tail = tail.subarray(-MAX_FROM_CMD_OUTPUT_BYTES);
25169
+ }
25170
+ };
25171
+ child.stdout?.on("data", keep);
25172
+ child.stderr?.on("data", keep);
25173
+ child.once(
25174
+ "error",
25175
+ (error2) => resolve5({ code: 127, output: `${error2.message}
25176
+ ` })
25177
+ );
25178
+ child.once(
25179
+ "exit",
25180
+ (code, signal) => resolve5({
25181
+ code: code ?? (signal ? 130 : 1),
25182
+ output: tail.toString("utf8")
25183
+ })
25184
+ );
25185
+ });
25186
+ }
24960
25187
  async function drainStdin() {
24961
25188
  const chunks = [];
24962
25189
  for await (const chunk of process.stdin) {
@@ -24976,6 +25203,21 @@ async function runPush(kind, file, flags, deps2) {
24976
25203
  "--basis records what a decision rested on \u2014 it only applies to `jentrix push decision`"
24977
25204
  );
24978
25205
  }
25206
+ if (flags.fromCmd !== void 0) {
25207
+ if (kind !== "log") {
25208
+ throw new UsageError(
25209
+ '--from-cmd captures a command\'s exit code and output as a LOG \u2014 it only applies to `jentrix push log --from-cmd "<command>"`'
25210
+ );
25211
+ }
25212
+ if (file || flags.ref) {
25213
+ throw new UsageError(
25214
+ "--from-cmd generates the LOG body from the command run \u2014 drop the file/--ref"
25215
+ );
25216
+ }
25217
+ if (!flags.fromCmd.trim()) {
25218
+ throw new UsageError("--from-cmd needs a non-empty command");
25219
+ }
25220
+ }
24979
25221
  if (flags.ref) {
24980
25222
  if (flags.basis?.length) {
24981
25223
  throw new UsageError(
@@ -25028,7 +25270,17 @@ async function runPush(kind, file, flags, deps2) {
25028
25270
  correlatedSessionId = marker.sessionId;
25029
25271
  fallbackTaskId = marker.taskId;
25030
25272
  }
25031
- const raw = file ? readFileSync4(file, "utf8") : await (deps2.readStdin ?? drainStdin)();
25273
+ let commandExit = null;
25274
+ let raw;
25275
+ if (flags.fromCmd !== void 0) {
25276
+ const command = flags.fromCmd.trim();
25277
+ deps2.writeErr(`running: ${command}`);
25278
+ const ran = await (deps2.runCommand ?? defaultRunCommand)(command);
25279
+ commandExit = ran.code;
25280
+ raw = fromCmdLogBody(command, ran.code, ran.output);
25281
+ } else {
25282
+ raw = file ? readFileSync4(file, "utf8") : await (deps2.readStdin ?? drainStdin)();
25283
+ }
25032
25284
  if (!raw.trim()) {
25033
25285
  throw new UsageError(
25034
25286
  file ? `${file} is empty` : "stdin is empty \u2014 pipe the content or name a file"
@@ -25052,6 +25304,9 @@ ${raw}` : raw;
25052
25304
  ...flags.title ? { title: flags.title } : {},
25053
25305
  ...flags.agent ? { producerLabel: flags.agent } : {},
25054
25306
  ...flags.agentEmoji ? { producerEmoji: flags.agentEmoji } : {},
25307
+ // D4: only the CLI-generated capture is attested — never a piped
25308
+ // body, whatever its kind.
25309
+ ...commandExit !== null ? { attested: true } : {},
25055
25310
  body
25056
25311
  })
25057
25312
  }
@@ -25107,7 +25362,12 @@ ${raw}` : raw;
25107
25362
  if (isMintableKind(kind) && payload.taskId) {
25108
25363
  await offerCardMint(kind, payload, flags, deps2, correlatedSessionId);
25109
25364
  }
25110
- return 0;
25365
+ if (commandExit !== null && commandExit !== 0) {
25366
+ deps2.writeErr(
25367
+ `command exited ${commandExit} \u2014 recorded in the LOG; exiting with the command's code`
25368
+ );
25369
+ }
25370
+ return commandExit ?? 0;
25111
25371
  } catch (error2) {
25112
25372
  if (error2 instanceof UsageError) {
25113
25373
  deps2.writeErr(`error: ${error2.message}`);
@@ -25421,6 +25681,9 @@ function registerPushCommand(program3, deps2, onExit2) {
25421
25681
  ).option(
25422
25682
  "--blocks",
25423
25683
  "with --yes on a findings/gap/issue push: the minted card BLOCKS the aligned task (acceptance-blocking) instead of RELATES_TO"
25684
+ ).option(
25685
+ "--from-cmd <command>",
25686
+ `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)`
25424
25687
  ).option("--json", "stable JSON output").action(
25425
25688
  async (kind, file, flags) => onExit2(await runPush(kind, file, flags, deps2))
25426
25689
  );
@@ -26351,6 +26614,10 @@ function parseArgsJson(raw, source) {
26351
26614
  }
26352
26615
  return { ok: true, value: parsed };
26353
26616
  }
26617
+ function workspaceIdShapeError(value) {
26618
+ if (/^[a-z0-9]{20,}$/.test(value)) return null;
26619
+ return `workspaceId "${value}" is not a workspace id (ids look like "cmt2cuhyo000004l38ddsim1y"). Find yours with \`jentrix workspace list\`, set it once as \`defaults.workspace\` in the config file, or use \`jentrix align --workspace <id-or-slug>\` \u2014 align is the command that accepts a slug.`;
26620
+ }
26354
26621
  async function runToolCommand(name, flags, deps2) {
26355
26622
  const maxWaitSeconds = Number(flags.maxWait);
26356
26623
  if (!Number.isFinite(maxWaitSeconds) || maxWaitSeconds < 0) {
@@ -26387,6 +26654,14 @@ async function runToolCommand(name, flags, deps2) {
26387
26654
  deps2.writeErr(`error: ${parsed.error}`);
26388
26655
  return EXIT_CODES.INVALID_INPUT;
26389
26656
  }
26657
+ const workspaceId = parsed.value.workspaceId;
26658
+ if (typeof workspaceId === "string") {
26659
+ const shapeError = workspaceIdShapeError(workspaceId);
26660
+ if (shapeError) {
26661
+ deps2.writeErr(`error: ${shapeError}`);
26662
+ return EXIT_CODES.INVALID_INPUT;
26663
+ }
26664
+ }
26390
26665
  let config2;
26391
26666
  try {
26392
26667
  config2 = resolveConfig({
@@ -27775,7 +28050,7 @@ function registerPluginCommand(program3, deps2, onExit2) {
27775
28050
  }
27776
28051
 
27777
28052
  // src/commands/runner.ts
27778
- import { execFile as execFile2, spawn as spawn2 } from "node:child_process";
28053
+ import { execFile as execFile2, spawn as spawn3 } from "node:child_process";
27779
28054
  import { constants, promises as fs } from "node:fs";
27780
28055
  import { homedir } from "node:os";
27781
28056
  import { delimiter, isAbsolute, resolve as resolve3 } from "node:path";
@@ -28209,7 +28484,7 @@ function runRunnerForeground(file, args) {
28209
28484
  }
28210
28485
  const target = execTarget(file, args);
28211
28486
  return new Promise((resolveRun, reject) => {
28212
- const child = spawn2(target.file, target.args, {
28487
+ const child = spawn3(target.file, target.args, {
28213
28488
  stdio: "inherit",
28214
28489
  ...target.options
28215
28490
  });
@@ -29223,7 +29498,11 @@ var sessionDeps = {
29223
29498
  configPath: CONFIG_PATH,
29224
29499
  resolveTarget: () => {
29225
29500
  const target = resolveConfig({ env: process.env, file: configFile() });
29226
- return { token: target.token, url: target.url };
29501
+ return {
29502
+ token: target.token,
29503
+ url: target.url,
29504
+ tokenSource: target.tokenSource
29505
+ };
29227
29506
  },
29228
29507
  ensureInstallationId: () => ensureInstallationId(HOME_CONFIG_PATH),
29229
29508
  connect: async (target) => {
@@ -29246,7 +29525,7 @@ var sessionDeps = {
29246
29525
  planPath
29247
29526
  ]);
29248
29527
  try {
29249
- const child = spawn3(target.file, target.args, {
29528
+ const child = spawn4(target.file, target.args, {
29250
29529
  detached: true,
29251
29530
  stdio: ["ignore", fd, fd],
29252
29531
  ...target.options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jentrix/cli",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
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
  {