@askance/cli 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ npx askance login
15
15
 
16
16
  ## What it does
17
17
 
18
- - **Hook handler** — intercepts tool calls before execution and evaluates them against your `.askance.yml` policy rules
18
+ - **Hook handler** — intercepts tool calls before execution and evaluates them against your cloud policy rules
19
19
  - **MCP server** — provides tools for the agent to wait for approvals and check for instructions
20
20
  - **CLI** — sets up config files and authenticates with the Askance cloud
21
21
 
@@ -39,33 +39,12 @@ npx askance init --all # All agents
39
39
 
40
40
  ## Policy rules
41
41
 
42
- Policy rules in `.askance.yml` control what happens when the agent uses a tool:
42
+ Policy rules are managed in the [Askance dashboard](https://app.askance.app) and control what happens when the agent uses a tool:
43
43
 
44
44
  - **allow** — tool call proceeds immediately
45
45
  - **gate** — queued for approval in the dashboard
46
46
  - **deny** — blocked automatically
47
47
 
48
- ```yaml
49
- rules:
50
- - name: "Allow read-only tools"
51
- match:
52
- tool: "^(Read|Glob|Grep|WebSearch)$"
53
- action: allow
54
-
55
- - name: "Gate file writes"
56
- match:
57
- tool: "^(Edit|Write)$"
58
- action: gate
59
- risk: medium
60
-
61
- - name: "Deny destructive commands"
62
- match:
63
- tool: "^Bash$"
64
- command: "(rm -rf|chmod 777)"
65
- action: deny
66
- risk: high
67
- ```
68
-
69
48
  ## Dashboard
70
49
 
71
50
  Manage approvals at [app.askance.app](https://app.askance.app) — approve, deny, or send instructions to your agent from any device.
@@ -9,6 +9,7 @@ exports.apiGet = apiGet;
9
9
  exports.apiPost = apiPost;
10
10
  exports.intercept = intercept;
11
11
  exports.getInstructions = getInstructions;
12
+ exports.sessionHeartbeat = sessionHeartbeat;
12
13
  exports.waitForInstruction = waitForInstruction;
13
14
  exports.readKeepAliveConfig = readKeepAliveConfig;
14
15
  const node_https_1 = __importDefault(require("node:https"));
@@ -164,6 +165,9 @@ function intercept(payload) {
164
165
  function getInstructions(projectId) {
165
166
  return apiGet(`/api/instructions/${projectId}`);
166
167
  }
168
+ function sessionHeartbeat(sessionId, lastMessage) {
169
+ return apiPost("/api/sessions/heartbeat", { sessionId, lastMessage });
170
+ }
167
171
  function waitForInstruction(projectId, timeoutMs) {
168
172
  return apiGet(`/api/instructions/${projectId}/wait?timeout=${timeoutMs}`, timeoutMs + 5000);
169
173
  }
@@ -6,6 +6,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const node_fs_1 = __importDefault(require("node:fs"));
7
7
  const api_client_1 = require("../common/api-client");
8
8
  const CONTEXT_LINES = 20;
9
+ const QUESTION_TOOLS = new Set([
10
+ "askuserquestion", "askfollowupquestion", "askquestion",
11
+ "ask_user", "ask_followup", "ask_question",
12
+ ]);
13
+ function isQuestionTool(toolName) {
14
+ return QUESTION_TOOLS.has(toolName.toLowerCase()) ||
15
+ toolName.toLowerCase().includes("ask");
16
+ }
9
17
  function readStdin() {
10
18
  return new Promise((resolve, reject) => {
11
19
  let data = "";
@@ -63,14 +71,16 @@ async function main() {
63
71
  // Extract recent Claude reasoning from transcript
64
72
  input.context_summary = extractContext(input.transcript_path ?? "");
65
73
  const config = (0, api_client_1.readConfig)();
74
+ const toolName = input.tool_name ?? "";
66
75
  // Map to cloud API format (PascalCase)
67
76
  const payload = {
68
- ToolName: input.tool_name ?? "",
77
+ ToolName: toolName,
69
78
  ToolInput: input.tool_input ? JSON.stringify(input.tool_input) : undefined,
70
79
  SessionId: input.session_id,
71
80
  Cwd: input.cwd,
72
81
  ContextSummary: input.context_summary,
73
82
  ProjectId: config.projectId || undefined,
83
+ IsQuestion: isQuestionTool(toolName),
74
84
  };
75
85
  const result = await (0, api_client_1.intercept)(payload);
76
86
  if (!result.ok || !result.data) {
@@ -111,15 +121,23 @@ async function main() {
111
121
  process.exit(0);
112
122
  }
113
123
  if (response.action === "pending") {
124
+ const isQuestion = response.isQuestion || isQuestionTool(toolName);
125
+ const reason = isQuestion
126
+ ? `PENDING QUESTION (${response.approvalId}): ${response.rule}. ` +
127
+ `This question has been forwarded to the operator on the Askance dashboard. ` +
128
+ `The operator will type an answer. Call the mcp__askance__wait tool with ` +
129
+ `approval_id "${response.approvalId}" to receive their response. ` +
130
+ `Continue working on other tasks while waiting.`
131
+ : `PENDING APPROVAL (${response.approvalId}): ${response.rule}. ` +
132
+ `This action is queued for human approval on the Askance dashboard. ` +
133
+ `Continue working on other tasks. When you need the result, call the ` +
134
+ `mcp__askance__wait tool with approval_id "${response.approvalId}" ` +
135
+ `to check if it has been approved. Once approved, retry the original action.`;
114
136
  const output = JSON.stringify({
115
137
  hookSpecificOutput: {
116
138
  hookEventName: "PreToolUse",
117
139
  permissionDecision: "deny",
118
- permissionDecisionReason: `PENDING APPROVAL (${response.approval_id}): ${response.rule}. ` +
119
- `This action is queued for human approval on the Askance dashboard. ` +
120
- `Continue working on other tasks. When you need the result, call the ` +
121
- `mcp__askance__wait tool with approval_id "${response.approval_id}" ` +
122
- `to check if it has been approved. Once approved, retry the original action.`,
140
+ permissionDecisionReason: reason,
123
141
  },
124
142
  });
125
143
  process.stdout.write(output);
@@ -1,5 +1,9 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_fs_1 = __importDefault(require("node:fs"));
3
7
  const api_client_1 = require("../common/api-client");
4
8
  function readStdin() {
5
9
  return new Promise((resolve, reject) => {
@@ -10,6 +14,46 @@ function readStdin() {
10
14
  process.stdin.on("error", reject);
11
15
  });
12
16
  }
17
+ function extractLastAssistantMessage(transcriptPath) {
18
+ try {
19
+ if (!transcriptPath || !node_fs_1.default.existsSync(transcriptPath))
20
+ return "";
21
+ const content = node_fs_1.default.readFileSync(transcriptPath, "utf-8");
22
+ const lines = content.trim().split("\n");
23
+ // Walk backwards to find the last assistant message
24
+ for (let i = lines.length - 1; i >= 0; i--) {
25
+ try {
26
+ const entry = JSON.parse(lines[i]);
27
+ if (entry.type === "assistant" && entry.message?.content) {
28
+ const parts = Array.isArray(entry.message.content)
29
+ ? entry.message.content
30
+ : [entry.message.content];
31
+ const texts = [];
32
+ for (const part of parts) {
33
+ if (typeof part === "string" && part.trim()) {
34
+ texts.push(part.trim());
35
+ }
36
+ else if (part?.type === "text" && part.text?.trim()) {
37
+ texts.push(part.text.trim());
38
+ }
39
+ }
40
+ if (texts.length > 0) {
41
+ const message = texts.join("\n");
42
+ // Truncate to 2000 chars for storage
43
+ return message.length > 2000 ? message.slice(0, 2000) + "..." : message;
44
+ }
45
+ }
46
+ }
47
+ catch {
48
+ // skip unparseable lines
49
+ }
50
+ }
51
+ return "";
52
+ }
53
+ catch {
54
+ return "";
55
+ }
56
+ }
13
57
  async function main() {
14
58
  let input;
15
59
  try {
@@ -23,6 +67,18 @@ async function main() {
23
67
  if (!config.projectId) {
24
68
  process.exit(0);
25
69
  }
70
+ // Extract and post the last assistant message to the dashboard
71
+ if (input.session_id && input.transcript_path) {
72
+ try {
73
+ const lastMessage = extractLastAssistantMessage(input.transcript_path);
74
+ if (lastMessage) {
75
+ await (0, api_client_1.sessionHeartbeat)(input.session_id, lastMessage);
76
+ }
77
+ }
78
+ catch {
79
+ // Non-critical — don't block on failure
80
+ }
81
+ }
26
82
  // Check for queued instructions from the dashboard
27
83
  try {
28
84
  const result = await (0, api_client_1.getInstructions)(config.projectId);
@@ -57,6 +57,15 @@ server.tool("wait", "Wait for a pending Askance approval to be decided. Use this
57
57
  }],
58
58
  };
59
59
  }
60
+ if (approvalStatus === "answered") {
61
+ const response = approval.response ?? "";
62
+ return {
63
+ content: [{
64
+ type: "text",
65
+ text: `OPERATOR RESPONSE for ${approval_id}:\n\n${response}\n\nThe operator has answered your question. Use this response to continue your work.`,
66
+ }],
67
+ };
68
+ }
60
69
  return {
61
70
  content: [{
62
71
  type: "text",
@@ -169,13 +178,13 @@ server.tool("check_instructions", "Poll the Askance dashboard for new operator i
169
178
  isError: true,
170
179
  };
171
180
  }
172
- // Read keep-alive config from local .askance.yml
181
+ // Read keep-alive config from .askance/config.json
173
182
  const kaConfig = (0, api_client_1.readKeepAliveConfig)();
174
183
  if (!kaConfig.enabled) {
175
184
  return {
176
185
  content: [{
177
186
  type: "text",
178
- text: "Keep-alive polling is disabled in .askance.yml. You may stop.",
187
+ text: "Keep-alive polling is disabled. You may stop.",
179
188
  }],
180
189
  };
181
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askance/cli",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Askance CLI — Tool call interception & approval management for AI coding agents",
5
5
  "license": "MIT",
6
6
  "homepage": "https://askance.app",