@kal-elsam/kairo-runtime 0.10.0 → 0.12.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.
Files changed (76) hide show
  1. package/global-template/components/agent-skills/LICENSE +21 -0
  2. package/global-template/components/agent-skills/PROVENANCE.md +26 -0
  3. package/global-template/components/agent-skills/skills/context-engineering/SKILL.md +289 -0
  4. package/global-template/components/agent-skills/skills/frontend-ui-engineering/SKILL.md +328 -0
  5. package/global-template/components/agent-skills/skills/observability-and-instrumentation/SKILL.md +203 -0
  6. package/global-template/components/agent-skills/skills/performance-optimization/SKILL.md +396 -0
  7. package/global-template/components/agent-skills/skills/source-driven-development/SKILL.md +194 -0
  8. package/global-template/components/catalog.json +29 -0
  9. package/package.json +5 -2
  10. package/scripts/cockpit-smoke.mjs +1 -1
  11. package/src/cli.js +136 -8
  12. package/src/global/component-builders.js +3 -1
  13. package/src/global/components/agent-skills.js +27 -0
  14. package/src/global/ink/brand/wordmark.js +50 -0
  15. package/src/global/ink/cockpit/primitives.js +45 -35
  16. package/src/global/ink/cockpit-control-center.js +104 -4
  17. package/src/global/ink/cockpit-scan.js +20 -2
  18. package/src/global/ink/cockpit-views.js +2 -1
  19. package/src/global/ink/ecosystem-updates-display.js +37 -0
  20. package/src/global/ink/launch-input.js +32 -1
  21. package/src/global/ink/orchestrator-app.js +14 -5
  22. package/src/global/ink/orchestrator-state.js +17 -2
  23. package/src/global/ink/system-resources-display.js +109 -0
  24. package/src/global/ink/theme.js +13 -8
  25. package/src/global/ink/use-orchestrator-data.js +40 -4
  26. package/src/global/ink/ux/live-activity.js +4 -7
  27. package/src/global/ink/ux/live-alerts.js +2 -5
  28. package/src/global/ink/ux/live-governance.js +2 -6
  29. package/src/global/ink/ux/live-orchestration.js +2 -5
  30. package/src/global/ink/ux/live-overview.js +88 -28
  31. package/src/global/ink/ux/live-settings.js +3 -6
  32. package/src/global/ink/ux/live-usage.js +5 -7
  33. package/src/global/ink/ux/semantic.js +23 -6
  34. package/src/global/mcp/kairo-mcp.js +230 -0
  35. package/src/global/observability/build-companion-snapshot.js +281 -0
  36. package/src/global/observability/build-observability-snapshot.js +24 -0
  37. package/src/global/observability/ecosystem-updates.js +224 -0
  38. package/src/global/observability/gentle-bundle-export.js +71 -0
  39. package/src/global/observability/gentle-bundle-import.js +122 -0
  40. package/src/global/observability/gentle-probe.js +155 -0
  41. package/src/global/observability/graphify-ops.js +133 -0
  42. package/src/global/observability/graphify-parse-cache.js +90 -0
  43. package/src/global/observability/graphify-probe.js +185 -0
  44. package/src/global/observability/hermes-activity.js +163 -0
  45. package/src/global/observability/hermes-probe.js +171 -0
  46. package/src/global/observability/index.js +85 -0
  47. package/src/global/observability/passive-snapshot-flight.js +93 -0
  48. package/src/global/observability/probe-contract.js +38 -0
  49. package/src/global/observability/probe-registry.js +30 -0
  50. package/src/global/observability/resource-advisor.js +71 -0
  51. package/src/global/observability/system-resources.js +171 -0
  52. package/src/global/runtime/alerts/alert-cli.js +31 -0
  53. package/src/global/runtime/alerts/alert-store.js +29 -6
  54. package/src/global/runtime/alerts/alert-validate.js +25 -1
  55. package/src/global/runtime/alerts/controlled-alert-actions.js +56 -0
  56. package/src/global/runtime/execution-adapters/claude.js +2 -1
  57. package/src/global/runtime/execution-adapters/codex.js +2 -1
  58. package/src/global/runtime/execution-adapters/create-execution-adapter.js +3 -14
  59. package/src/global/runtime/execution-adapters/cursor.js +2 -1
  60. package/src/global/runtime/execution-adapters/opencode.js +2 -1
  61. package/src/global/runtime/execution-adapters/pi.js +2 -1
  62. package/src/global/runtime/review/index.js +1 -1
  63. package/src/global/runtime/review/review-cli.js +113 -3
  64. package/src/global/runtime/review/review-git.js +142 -11
  65. package/src/global/runtime/review/review-patch.js +2 -0
  66. package/src/global/runtime/review/review-receipts.js +12 -7
  67. package/src/global/runtime/review/review-runner.js +2 -2
  68. package/src/global/runtime/review/review-types.js +8 -5
  69. package/src/global/runtime/review/review-validate.js +5 -1
  70. package/src/global/runtime/run-cli.js +2 -0
  71. package/src/global/runtime/run-manager.js +39 -18
  72. package/src/global/runtime/run-permissions.js +231 -0
  73. package/src/global/runtime/run-profile.js +2 -0
  74. package/src/global/runtime/run-supervisor.js +77 -37
  75. package/src/global/runtime/run-types.js +2 -0
  76. package/src/global/updates-cli.js +41 -0
@@ -0,0 +1,31 @@
1
+ import { resolveHomeDir } from "../../paths.js";
2
+ import { printJson } from "../../json-output.js";
3
+ import { commandHeader } from "../../brand/index.js";
4
+ import { formatCliCommand } from "../../brand/cli.js";
5
+ import { controlledDismissAlert, controlledResolveAlert } from "./controlled-alert-actions.js";
6
+
7
+ export async function runGlobalAlerts(options) {
8
+ const homeDir = resolveHomeDir();
9
+ const action = options.alertsAction;
10
+ const alertId = options.alertId;
11
+ if ((action !== "resolve" && action !== "dismiss") || !alertId) {
12
+ throw new Error(`Use: ${formatCliCommand("alerts resolve|dismiss <alertId> --confirm-…")}`);
13
+ }
14
+ const isResolve = action === "resolve";
15
+ const confirmed = Boolean(isResolve ? options.confirmResolve : options.confirmDismiss);
16
+ const result = await (isResolve ? controlledResolveAlert : controlledDismissAlert)({
17
+ alertId, confirmed, source: "cli", homeDir
18
+ });
19
+ if (options.json) {
20
+ printJson({
21
+ ok: result.ok, code: result.code, alertId, state: result.alert?.state ?? null,
22
+ permissionAuthority: result.permissionAuthority, diagnostics: result.diagnostics
23
+ });
24
+ } else if (result.ok) {
25
+ console.log(`${commandHeader(`alerts ${action}`)}\n${alertId} → ${result.alert.state}`);
26
+ } else {
27
+ console.error(`alerts ${action} failed (${result.code}).`);
28
+ }
29
+ if (!result.ok) process.exitCode = 2;
30
+ return result;
31
+ }
@@ -10,6 +10,11 @@ import {
10
10
  createAlertFingerprint
11
11
  } from "./alert-types.js";
12
12
  import { assertAlertSecretFree } from "./alert-validate.js";
13
+ import {
14
+ PermissionAuthorityError,
15
+ UNSAFE_OPERATIONS,
16
+ assertUnsafePermissionAuthority
17
+ } from "../run-permissions.js";
13
18
 
14
19
  const TERMINAL_ALERT_STATES = new Set([ALERT_STATES.RESOLVED, ALERT_STATES.DISMISSED]);
15
20
 
@@ -190,7 +195,7 @@ export async function listAlerts({ homeDir, state = null, limit = null } = {}) {
190
195
  return Number.isInteger(limit) && limit >= 0 ? filtered.slice(0, limit) : filtered;
191
196
  }
192
197
 
193
- async function transitionAlert(alertId, nextState, { homeDir } = {}) {
198
+ async function transitionAlert(alertId, nextState, { homeDir, permissionAuthority } = {}) {
194
199
  const current = await loadAlert(alertId, { homeDir });
195
200
  if (current.state !== ALERT_STATES.OPEN) return current;
196
201
  const now = new Date().toISOString();
@@ -198,7 +203,8 @@ async function transitionAlert(alertId, nextState, { homeDir } = {}) {
198
203
  ...current,
199
204
  state: nextState,
200
205
  updatedAt: now,
201
- resolvedAt: now
206
+ resolvedAt: now,
207
+ permissionAuthority
202
208
  }, { homeDir });
203
209
  const indexPath = openIndexPath(homeDir, current.fingerprint);
204
210
  await unlink(indexPath).catch((error) => {
@@ -207,10 +213,27 @@ async function transitionAlert(alertId, nextState, { homeDir } = {}) {
207
213
  return updated;
208
214
  }
209
215
 
210
- export async function resolveAlert(alertId, { homeDir } = {}) {
211
- return transitionAlert(alertId, ALERT_STATES.RESOLVED, { homeDir });
216
+ function requireMutationAuthority(permissionAuthority, expectedOperation) {
217
+ try {
218
+ return assertUnsafePermissionAuthority(permissionAuthority, { expectedOperation });
219
+ } catch (error) {
220
+ if (error instanceof PermissionAuthorityError) {
221
+ throw new AlertStoreError(error.message, {
222
+ code: error.code ?? "permission_authority_required", details: error.details
223
+ });
224
+ }
225
+ throw error;
226
+ }
212
227
  }
213
228
 
214
- export async function dismissAlert(alertId, { homeDir } = {}) {
215
- return transitionAlert(alertId, ALERT_STATES.DISMISSED, { homeDir });
229
+ export async function resolveAlert(alertId, { homeDir, permissionAuthority } = {}) {
230
+ return transitionAlert(alertId, ALERT_STATES.RESOLVED, {
231
+ homeDir, permissionAuthority: requireMutationAuthority(permissionAuthority, UNSAFE_OPERATIONS.ALERT_RESOLVE)
232
+ });
233
+ }
234
+
235
+ export async function dismissAlert(alertId, { homeDir, permissionAuthority } = {}) {
236
+ return transitionAlert(alertId, ALERT_STATES.DISMISSED, {
237
+ homeDir, permissionAuthority: requireMutationAuthority(permissionAuthority, UNSAFE_OPERATIONS.ALERT_DISMISS)
238
+ });
216
239
  }
@@ -19,8 +19,10 @@ const FORBIDDEN_KEYS = new Set([
19
19
  ]);
20
20
  const ALLOWED = new Set([
21
21
  "version", "alertId", "kind", "severity", "title", "summary",
22
- "source", "fingerprint", "state", "createdAt", "updatedAt", "resolvedAt"
22
+ "source", "fingerprint", "state", "createdAt", "updatedAt", "resolvedAt",
23
+ "permissionAuthority"
23
24
  ]);
25
+ const PA_KEYS = new Set(["mode", "source", "consent", "operation"]);
24
26
 
25
27
  export class AlertValidationError extends Error {
26
28
  constructor(message, { code, details = null } = {}) {
@@ -102,6 +104,28 @@ export function assertAlertSecretFree(alert) {
102
104
  code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
103
105
  });
104
106
  }
107
+ if (alert.permissionAuthority != null) {
108
+ const pa = alert.permissionAuthority;
109
+ if (!pa || typeof pa !== "object" || Array.isArray(pa)) {
110
+ throw new AlertValidationError("Invalid alert.permissionAuthority.", {
111
+ code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
112
+ });
113
+ }
114
+ for (const key of Object.keys(pa)) {
115
+ if (!PA_KEYS.has(key) || typeof pa[key] !== "string" || !pa[key].trim()) {
116
+ throw new AlertValidationError(`Invalid alert.permissionAuthority.${key}.`, {
117
+ code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT, details: { key }
118
+ });
119
+ }
120
+ }
121
+ for (const key of PA_KEYS) {
122
+ if (typeof pa[key] !== "string" || !pa[key].trim()) {
123
+ throw new AlertValidationError(`Missing alert.permissionAuthority.${key}.`, {
124
+ code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT, details: { key }
125
+ });
126
+ }
127
+ }
128
+ }
105
129
  const expected = createAlertFingerprint({
106
130
  kind: alert.kind,
107
131
  source: alert.source,
@@ -0,0 +1,56 @@
1
+ import {
2
+ CONSENT_TYPES,
3
+ PermissionAuthorityError,
4
+ UNSAFE_OPERATIONS,
5
+ authorizeUnsafeOperation as defaultAuthorize
6
+ } from "../run-permissions.js";
7
+ import { dismissAlert, resolveAlert } from "./alert-store.js";
8
+
9
+ function fail(code, message, permissionAuthority = null) {
10
+ return { ok: false, code, message, alert: null, permissionAuthority, diagnostics: [code] };
11
+ }
12
+
13
+ async function runControlled({
14
+ operation, confirmed, source, consentType, alertId, homeDir,
15
+ authorizeUnsafeOperation = defaultAuthorize, mutate
16
+ }) {
17
+ let permissionAuthority = null;
18
+ try {
19
+ ({ permissionAuthority } = authorizeUnsafeOperation({
20
+ operation, confirmed: Boolean(confirmed), source, consentType
21
+ }));
22
+ } catch (error) {
23
+ if (error instanceof PermissionAuthorityError) {
24
+ return fail(error.code ?? "unsafe_consent_required", error.message, null);
25
+ }
26
+ throw error;
27
+ }
28
+ const alert = await mutate(alertId, { homeDir, permissionAuthority });
29
+ return {
30
+ ok: true, code: "ok", message: null, alert, permissionAuthority, diagnostics: []
31
+ };
32
+ }
33
+
34
+ export async function controlledResolveAlert({
35
+ alertId, confirmed = false, source = "cli", consentType = null, homeDir,
36
+ authorizeUnsafeOperation, resolve = resolveAlert
37
+ } = {}) {
38
+ return runControlled({
39
+ operation: UNSAFE_OPERATIONS.ALERT_RESOLVE, confirmed, source, consentType, alertId, homeDir,
40
+ authorizeUnsafeOperation,
41
+ mutate: (id, opts) => resolve(id, opts)
42
+ });
43
+ }
44
+
45
+ export async function controlledDismissAlert({
46
+ alertId, confirmed = false, source = "cli", consentType = null, homeDir,
47
+ authorizeUnsafeOperation, dismiss = dismissAlert
48
+ } = {}) {
49
+ return runControlled({
50
+ operation: UNSAFE_OPERATIONS.ALERT_DISMISS, confirmed, source, consentType, alertId, homeDir,
51
+ authorizeUnsafeOperation,
52
+ mutate: (id, opts) => dismiss(id, opts)
53
+ });
54
+ }
55
+
56
+ export { CONSENT_TYPES, UNSAFE_OPERATIONS };
@@ -58,7 +58,8 @@ export default createExecutionAdapter({
58
58
  tokens: true,
59
59
  diff: false,
60
60
  cancel: true,
61
- transcript: true
61
+ transcript: true,
62
+ permissionModes: ["force", "yolo"]
62
63
  },
63
64
  buildLaunch: buildClaudeLaunch,
64
65
  parseEventLine: parseClaudeEventLine
@@ -72,7 +72,8 @@ export default createExecutionAdapter({
72
72
  diff: false,
73
73
  cancel: true,
74
74
  transcript: true,
75
- reviewCompatible: true
75
+ reviewCompatible: true,
76
+ permissionModes: ["yolo"]
76
77
  },
77
78
  buildLaunch: buildCodexLaunch,
78
79
  parseEventLine: parseCodexEventLine
@@ -1,4 +1,7 @@
1
1
  import { isExecutableAvailable } from "../../cli-probe.js";
2
+ import { buildPermissionsArgs } from "../run-permissions.js";
3
+
4
+ export { buildPermissionsArgs };
2
5
 
3
6
  export function createExecutionAdapter({
4
7
  id,
@@ -77,17 +80,3 @@ export function parseNdjsonLine(line) {
77
80
  return null;
78
81
  }
79
82
  }
80
-
81
- export function buildPermissionsArgs(permissions = []) {
82
- const normalized = new Set(permissions.map((entry) => String(entry).toLowerCase()));
83
-
84
- if (normalized.has("all") || normalized.has("force")) {
85
- return ["--force"];
86
- }
87
-
88
- if (normalized.has("yolo") || normalized.has("dangerously-skip-permissions")) {
89
- return ["--dangerously-skip-permissions"];
90
- }
91
-
92
- return [];
93
- }
@@ -96,7 +96,8 @@ export default createExecutionAdapter({
96
96
  tokens: true,
97
97
  diff: false,
98
98
  cancel: true,
99
- transcript: true
99
+ transcript: true,
100
+ permissionModes: ["force", "yolo"]
100
101
  },
101
102
  checkAvailability: checkCursorAvailability,
102
103
  buildLaunch: buildCursorLaunch,
@@ -31,7 +31,8 @@ export default createExecutionAdapter({
31
31
  tokens: false,
32
32
  diff: false,
33
33
  cancel: true,
34
- transcript: false
34
+ transcript: false,
35
+ permissionModes: ["force"]
35
36
  },
36
37
  buildLaunch: buildOpencodeLaunch,
37
38
  parseEventLine: null
@@ -186,7 +186,8 @@ export default createExecutionAdapter({
186
186
  diff: false,
187
187
  cancel: true,
188
188
  transcript: true,
189
- reviewCompatible: true
189
+ reviewCompatible: true,
190
+ permissionModes: ["read-only"]
190
191
  },
191
192
  checkAvailability: checkPiAvailability,
192
193
  buildLaunch: buildPiLaunch,
@@ -6,7 +6,7 @@ export {
6
6
  } from "./review-types.js";
7
7
  export {
8
8
  resolveReviewSnapshot, fingerprintReviewSnapshot, detectReviewSnapshotDrift,
9
- readReviewRegularFile
9
+ verifyStagedReviewReceipt, readReviewRegularFile
10
10
  } from "./review-git.js";
11
11
  export {
12
12
  REVIEW_PATCH_ERROR_CODES, filterDiffToAdmittedPaths, buildScopedReviewPatch
@@ -5,10 +5,12 @@ import { formatCliCommand } from "../../brand/cli.js";
5
5
  import {
6
6
  isInteractiveTerminal, promptApplyConfirmation
7
7
  } from "../../apply-confirmation.js";
8
+ import { exportGentleReviewBundle as defaultExportGentleReviewBundle } from "../../observability/gentle-bundle-export.js";
9
+ import { importGentleReviewBundle as defaultImportGentleReviewBundle } from "../../observability/gentle-bundle-import.js";
8
10
  import {
9
11
  REVIEW_EXIT_CODES, REVIEW_SEVERITIES,
10
12
  assertReceiptSecretFree, assertSafeReviewId,
11
- listReviewReceipts, loadReviewReceipt
13
+ listReviewReceipts, loadReviewReceipt, verifyStagedReviewReceipt
12
14
  } from "./index.js";
13
15
  import { runReview } from "./review-runner.js";
14
16
 
@@ -83,7 +85,8 @@ export async function runGlobalReview(options, packageManifest, deps = {}) {
83
85
 
84
86
  const result = await (deps.runReview ?? runReview)({
85
87
  cwd: options.cwd, agent: options.agent, base: options.base ?? null,
86
- commit: options.commit ?? null, model: options.model ?? null,
88
+ commit: options.commit ?? null, staged: Boolean(options.staged),
89
+ model: options.model ?? null,
87
90
  includePrivate: Boolean(options.includePrivate),
88
91
  privateConfirmed: consent.privateConfirmed, failOn,
89
92
  homeDir, cliVersion: packageManifest?.version ?? null
@@ -138,7 +141,114 @@ export async function runGlobalReviews(options, _packageManifest, deps = {}) {
138
141
  else printReviewHuman(receipt, REVIEW_EXIT_CODES.OK);
139
142
  return { receipt };
140
143
  }
141
- throw new Error(`Unknown reviews action "${action}". Use list or show.`);
144
+ if (action === "verify") {
145
+ if (!options.reviewId) {
146
+ throw new Error(`Missing review id. Use: ${formatCliCommand("reviews verify <reviewId> --staged")}`);
147
+ }
148
+ if (!options.staged) {
149
+ throw new Error(`Staged verification requires --staged. Use: ${formatCliCommand("reviews verify <reviewId> --staged")}`);
150
+ }
151
+ try { assertSafeReviewId(options.reviewId); }
152
+ catch { throw new Error(`Invalid review id "${options.reviewId}".`); }
153
+ let receipt;
154
+ try {
155
+ receipt = publicReceipt(await loadReviewReceipt(options.reviewId, { homeDir }));
156
+ } catch {
157
+ throw new Error(`Review receipt not found: ${options.reviewId}`);
158
+ }
159
+ const verified = await (deps.verifyStaged ?? verifyStagedReviewReceipt)(receipt, {
160
+ cwd: options.cwd ?? process.cwd()
161
+ });
162
+ const exitCode = verified.ok ? REVIEW_EXIT_CODES.OK : REVIEW_EXIT_CODES.ERROR;
163
+ if (options.json) {
164
+ printJson({
165
+ ok: verified.ok, exitCode, stale: verified.stale, reviewId: receipt.reviewId,
166
+ previousFingerprint: verified.previousFingerprint,
167
+ nextFingerprint: verified.nextFingerprint, headSha: verified.headSha
168
+ });
169
+ } else {
170
+ console.log(commandHeader(`reviews verify ${receipt.reviewId}`));
171
+ console.log(verified.ok ? "Staged candidate matches receipt." : "Staged candidate drifted; receipt invalid.");
172
+ }
173
+ process.exitCode = exitCode;
174
+ return { ...verified, exitCode, receipt };
175
+ }
176
+ if (action === "export") {
177
+ if (!options.lineage) {
178
+ throw new Error(
179
+ `Missing lineage. Use: ${formatCliCommand("reviews export <lineage> --out <path>")}`
180
+ );
181
+ }
182
+ if (!options.outPath) {
183
+ throw new Error(
184
+ `Missing --out. Use: ${formatCliCommand("reviews export <lineage> --out <path>")}`
185
+ );
186
+ }
187
+ const exported = await (deps.exportGentleReviewBundle ?? defaultExportGentleReviewBundle)({
188
+ lineage: options.lineage,
189
+ outPath: options.outPath,
190
+ cwd: options.cwd ?? process.cwd()
191
+ });
192
+ const exitCode = exported.ok ? REVIEW_EXIT_CODES.OK : REVIEW_EXIT_CODES.ERROR;
193
+ process.exitCode = exitCode;
194
+ if (options.json) {
195
+ printJson({
196
+ ok: exported.ok,
197
+ exitCode,
198
+ code: exported.code,
199
+ lineage: exported.lineage,
200
+ outPath: exported.outPath,
201
+ diagnostics: exported.diagnostics,
202
+ providerStatus: exported.providerStatus ?? null,
203
+ timedOut: Boolean(exported.timedOut)
204
+ });
205
+ } else {
206
+ console.log(commandHeader(`reviews export ${exported.lineage ?? options.lineage}`));
207
+ if (exported.ok) console.log(`Exported via Gentle AI → ${exported.outPath}`);
208
+ else {
209
+ console.error(`Export failed (${exported.code}).`);
210
+ for (const line of exported.diagnostics ?? []) console.error(` ${line}`);
211
+ }
212
+ }
213
+ return { ...exported, exitCode };
214
+ }
215
+ if (action === "import") {
216
+ if (!options.bundlePath) {
217
+ throw new Error(
218
+ `Missing --bundle. Use: ${formatCliCommand("reviews import --bundle <path> --confirm-import")}`
219
+ );
220
+ }
221
+ const imported = await (deps.importGentleReviewBundle ?? defaultImportGentleReviewBundle)({
222
+ bundlePath: options.bundlePath,
223
+ cwd: options.cwd ?? process.cwd(),
224
+ confirmImport: Boolean(options.confirmImport)
225
+ });
226
+ const exitCode = imported.ok ? REVIEW_EXIT_CODES.OK : REVIEW_EXIT_CODES.ERROR;
227
+ process.exitCode = exitCode;
228
+ if (options.json) {
229
+ printJson({
230
+ ok: imported.ok,
231
+ exitCode,
232
+ code: imported.code,
233
+ mutationOutcome: imported.mutationOutcome,
234
+ bundlePath: imported.bundlePath,
235
+ cwd: imported.cwd,
236
+ diagnostics: imported.diagnostics,
237
+ providerStatus: imported.providerStatus ?? null,
238
+ timedOut: Boolean(imported.timedOut),
239
+ permissionAuthority: imported.permissionAuthority ?? null
240
+ });
241
+ } else {
242
+ console.log(commandHeader(`reviews import ${imported.bundlePath ?? options.bundlePath}`));
243
+ if (imported.ok) console.log("Imported via Gentle AI (provider-owned authority).");
244
+ else {
245
+ console.error(`Import failed (${imported.code}; mutation=${imported.mutationOutcome}).`);
246
+ for (const line of imported.diagnostics ?? []) console.error(` ${line}`);
247
+ }
248
+ }
249
+ return { ...imported, exitCode };
250
+ }
251
+ throw new Error(`Unknown reviews action "${action}". Use list, show, verify, export, or import.`);
142
252
  } catch (error) {
143
253
  const exitCode = REVIEW_EXIT_CODES.ERROR;
144
254
  const message = String(error?.message ?? error);
@@ -91,24 +91,94 @@ function parseNameStatus(text) {
91
91
  function fingerprintPayload(s) {
92
92
  return {
93
93
  mode: s.mode, headSha: s.headSha, base: s.base ?? null, commit: s.commit ?? null,
94
- files: s.files.map((f) => ({
95
- path: f.path, sourcePath: f.sourcePath ?? null, status: f.status, hash: f.hash, changedLines: f.changedLines
96
- })),
94
+ files: s.files.map((f) => {
95
+ const entry = {
96
+ path: f.path, sourcePath: f.sourcePath ?? null, status: f.status,
97
+ hash: f.hash, changedLines: f.changedLines
98
+ };
99
+ if (f.mode != null) entry.mode = f.mode;
100
+ return entry;
101
+ }),
97
102
  excluded: s.excluded.map((e) => ({ path: e.path, reason: e.reason }))
98
103
  };
99
104
  }
100
105
 
106
+ function parseRawDiff(text) {
107
+ return text.split("\n").filter((l) => l.startsWith(":")).map((line) => {
108
+ const columns = line.slice(1).split("\t");
109
+ const parts = columns[0].trim().split(/\s+/);
110
+ const [oldMode, newMode, oldHash, newHash, status] = parts;
111
+ const pathParts = columns.slice(1).map(unquotePath);
112
+ if (pathParts.length >= 2) {
113
+ return {
114
+ oldMode, newMode, oldHash, newHash, status,
115
+ sourcePath: pathParts[0], path: pathParts[1]
116
+ };
117
+ }
118
+ return {
119
+ oldMode, newMode, oldHash, newHash, status,
120
+ sourcePath: null, path: pathParts[0]
121
+ };
122
+ });
123
+ }
124
+
125
+ async function resolveObjectIdLength(cwd, execFileImpl) {
126
+ try {
127
+ const format = (await git(cwd, ["rev-parse", "--show-object-format"], execFileImpl)).trim();
128
+ return format === "sha256" ? 64 : 40;
129
+ } catch {
130
+ return 40;
131
+ }
132
+ }
133
+
134
+ function assertFullObjectId(hash, expectedLength, { path, role = "blob" } = {}) {
135
+ if (hash == null || hash === "" || /^0+$/.test(hash)) {
136
+ throw new ReviewSnapshotError(
137
+ `Missing ${role} object ID for staged path "${path}".`,
138
+ {
139
+ code: REVIEW_SNAPSHOT_ERROR_CODES.TRUNCATED_OBJECT_ID,
140
+ details: { path, role, hash, expectedLength }
141
+ }
142
+ );
143
+ }
144
+ if (!new RegExp(`^[a-f0-9]{${expectedLength}}$`, "i").test(hash)) {
145
+ throw new ReviewSnapshotError(
146
+ `Truncated or invalid ${role} object ID for "${path}" `
147
+ + `(got length ${String(hash).length}, expected ${expectedLength}).`,
148
+ {
149
+ code: REVIEW_SNAPSHOT_ERROR_CODES.TRUNCATED_OBJECT_ID,
150
+ details: { path, role, hash, expectedLength }
151
+ }
152
+ );
153
+ }
154
+ return hash.toLowerCase();
155
+ }
156
+
157
+ async function blobIsBinary(cwd, blobHash, execFileImpl) {
158
+ if (!blobHash || /^0+$/.test(blobHash)) return false;
159
+ try {
160
+ const { stdout } = await execFileImpl("git", ["cat-file", "-p", blobHash], {
161
+ cwd, encoding: null, maxBuffer: 8 * 1024 * 1024
162
+ });
163
+ return isBinaryContent(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? ""));
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
101
169
  /** Bounded Git review snapshot via argv-only git (no shell, no repo writes). */
102
170
  export async function resolveReviewSnapshot({
103
- cwd, base = null, commit = null, includePrivate = false, privateConfirmed = false,
171
+ cwd, base = null, commit = null, staged = false, includePrivate = false, privateConfirmed = false,
104
172
  execFileImpl = defaultExecFile
105
173
  } = {}) {
106
- const mode = resolveReviewScopeMode({ base, commit });
174
+ const mode = resolveReviewScopeMode({ base, commit, staged });
107
175
  await assertGitRepo(cwd, execFileImpl);
108
176
  const headSha = (await git(cwd, ["rev-parse", "HEAD"], execFileImpl)).trim();
109
177
  let rawEntries = [];
110
178
  let numstat = new Map();
111
179
  let diffBytes = 0;
180
+ let rawIdentity = new Map();
181
+ let stagedObjectIdLength = null;
112
182
 
113
183
  if (mode === REVIEW_SCOPE_MODES.WORKING_TREE) {
114
184
  rawEntries = parsePorcelain(await git(cwd, ["status", "--porcelain=v1", "-uall"], execFileImpl));
@@ -118,6 +188,17 @@ export async function resolveReviewSnapshot({
118
188
  ]);
119
189
  diffBytes = Buffer.byteLength(await git(cwd, ["diff"], execFileImpl), "utf8")
120
190
  + Buffer.byteLength(await git(cwd, ["diff", "--cached"], execFileImpl), "utf8");
191
+ } else if (mode === REVIEW_SCOPE_MODES.STAGED) {
192
+ stagedObjectIdLength = await resolveObjectIdLength(cwd, execFileImpl);
193
+ rawEntries = parseNameStatus(await git(cwd, ["diff", "--cached", "--name-status"], execFileImpl));
194
+ numstat = parseNumstat(await git(cwd, ["diff", "--cached", "--numstat"], execFileImpl));
195
+ diffBytes = Buffer.byteLength(await git(cwd, ["diff", "--cached"], execFileImpl), "utf8");
196
+ const rawText = await git(cwd, [
197
+ "-c", "core.abbrev=no", "diff", "--cached", "--raw", "--full-index"
198
+ ], execFileImpl);
199
+ for (const entry of parseRawDiff(rawText)) {
200
+ rawIdentity.set(entry.path, entry);
201
+ }
121
202
  } else if (mode === REVIEW_SCOPE_MODES.BASE) {
122
203
  const range = `${base}...HEAD`;
123
204
  await git(cwd, ["rev-parse", "--verify", base], execFileImpl);
@@ -127,10 +208,10 @@ export async function resolveReviewSnapshot({
127
208
  } else {
128
209
  await git(cwd, ["rev-parse", "--verify", `${commit}^{commit}`], execFileImpl);
129
210
  rawEntries = parseNameStatus(
130
- await git(cwd, ["diff-tree", "--no-commit-id", "--name-status", "-r", commit], execFileImpl)
211
+ await git(cwd, ["diff-tree", "--no-commit-id", "--name-status", "-r", "--root", commit], execFileImpl)
131
212
  );
132
213
  numstat = parseNumstat(
133
- await git(cwd, ["diff-tree", "--no-commit-id", "--numstat", "-r", commit], execFileImpl)
214
+ await git(cwd, ["diff-tree", "--no-commit-id", "--numstat", "-r", "--root", commit], execFileImpl)
134
215
  );
135
216
  diffBytes = Buffer.byteLength(await git(cwd, ["show", "--format=", "--patch", commit], execFileImpl), "utf8");
136
217
  }
@@ -149,6 +230,7 @@ export async function resolveReviewSnapshot({
149
230
  }
150
231
 
151
232
  let hash;
233
+ let modeBits = null;
152
234
  let changedLines = numstat.get(path) ?? 0;
153
235
  let bytes = 0;
154
236
  const deleted = /D/.test(entry.status);
@@ -168,14 +250,37 @@ export async function resolveReviewSnapshot({
168
250
  bytes = buffer.length;
169
251
  if (!numstat.has(path)) changedLines = buffer.toString("utf8").split(/\r?\n/).length;
170
252
  if (entry.status === "??") diffBytes += bytes;
253
+ } else if (mode === REVIEW_SCOPE_MODES.STAGED) {
254
+ const identity = rawIdentity.get(path);
255
+ if (!identity) {
256
+ throw new ReviewSnapshotError(
257
+ `Missing staged raw identity for "${path}".`,
258
+ {
259
+ code: REVIEW_SNAPSHOT_ERROR_CODES.TRUNCATED_OBJECT_ID,
260
+ details: { path }
261
+ }
262
+ );
263
+ }
264
+ const blobHash = deleted ? identity.oldHash : identity.newHash;
265
+ modeBits = deleted ? identity.oldMode : identity.newMode;
266
+ if (modeBits === "000000") modeBits = identity.oldMode;
267
+ const fullHash = assertFullObjectId(blobHash, stagedObjectIdLength, { path });
268
+ if (await blobIsBinary(cwd, fullHash, execFileImpl)) {
269
+ excluded.push({ path, reason: "binary" });
270
+ continue;
271
+ }
272
+ hash = fullHash;
171
273
  } else {
172
- try { hash = (await git(cwd, ["rev-parse", `HEAD:${path}`], execFileImpl)).trim(); }
274
+ const blobRef = mode === REVIEW_SCOPE_MODES.COMMIT ? `${commit}:${path}` : `HEAD:${path}`;
275
+ try { hash = (await git(cwd, ["rev-parse", blobRef], execFileImpl)).trim(); }
173
276
  catch { hash = createHash("sha256").update(`${entry.status}:${path}`).digest("hex"); }
174
277
  }
175
278
 
176
- files.push({
279
+ const file = {
177
280
  path, sourcePath, status: entry.status.trim(), hash, changedLines, bytes
178
- });
281
+ };
282
+ if (modeBits != null) file.mode = modeBits;
283
+ files.push(file);
179
284
  }
180
285
 
181
286
  requirePrivateConsent({ includePrivate, privateConfirmed, privatePaths: privateCandidates });
@@ -185,7 +290,8 @@ export async function resolveReviewSnapshot({
185
290
  assertWithinReviewLimits({ fileCount: files.length, changedLines, diffBytes });
186
291
 
187
292
  const snapshot = {
188
- version: 1, mode, cwd, headSha, base: base ?? null, commit: commit ?? null,
293
+ version: mode === REVIEW_SCOPE_MODES.STAGED ? 2 : 1,
294
+ mode, cwd, headSha, base: base ?? null, commit: commit ?? null,
189
295
  files, excluded, totals: { fileCount: files.length, changedLines, diffBytes }, fingerprint: null
190
296
  };
191
297
  snapshot.fingerprint = canonicalFingerprint(fingerprintPayload(snapshot));
@@ -199,6 +305,7 @@ export function fingerprintReviewSnapshot(snapshot) {
199
305
  export async function detectReviewSnapshotDrift(previous, options = {}) {
200
306
  const next = await resolveReviewSnapshot({
201
307
  cwd: previous.cwd, base: previous.base, commit: previous.commit,
308
+ staged: previous.mode === REVIEW_SCOPE_MODES.STAGED,
202
309
  includePrivate: options.includePrivate ?? false,
203
310
  privateConfirmed: options.privateConfirmed ?? false,
204
311
  execFileImpl: options.execFileImpl
@@ -210,3 +317,27 @@ export async function detectReviewSnapshotDrift(previous, options = {}) {
210
317
  next
211
318
  };
212
319
  }
320
+
321
+ /** Fail-closed staged candidate check against a receipt snapshot. */
322
+ export async function verifyStagedReviewReceipt(receipt, {
323
+ cwd = null, includePrivate = false, privateConfirmed = false, execFileImpl = defaultExecFile
324
+ } = {}) {
325
+ const snapshot = receipt?.snapshot;
326
+ if (!snapshot || snapshot.mode !== REVIEW_SCOPE_MODES.STAGED) {
327
+ throw new ReviewSnapshotError("Staged verification requires a staged review receipt.", {
328
+ code: REVIEW_SNAPSHOT_ERROR_CODES.INVALID_SCOPE,
329
+ details: { mode: snapshot?.mode ?? null }
330
+ });
331
+ }
332
+ const drift = await detectReviewSnapshotDrift(
333
+ { ...snapshot, cwd: cwd ?? snapshot.cwd },
334
+ { includePrivate, privateConfirmed, execFileImpl }
335
+ );
336
+ return {
337
+ ok: !drift.stale,
338
+ stale: drift.stale,
339
+ previousFingerprint: drift.previousFingerprint,
340
+ nextFingerprint: drift.nextFingerprint,
341
+ headSha: drift.next.headSha
342
+ };
343
+ }
@@ -101,6 +101,8 @@ export async function buildScopedReviewPatch(snapshot, { execFileImpl = defaultE
101
101
  raw = await gitDiff(
102
102
  cwd, ["show", "--format=", "--patch", snapshot.commit, "--", ...admitted], execFileImpl
103
103
  );
104
+ } else if (snapshot.mode === REVIEW_SCOPE_MODES.STAGED) {
105
+ raw = await gitDiff(cwd, ["diff", "--cached", "--", ...admitted], execFileImpl);
104
106
  } else {
105
107
  raw = [
106
108
  await gitDiff(cwd, ["diff", "--", ...admitted], execFileImpl),