@chinhae/atw-cli 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "0.2.7",
3
+ "description": "Active-task pointers are no longer borrowed by count (#608 upstream); the Pi extension inherits a pointer only through the window's own session lineage and tells a fresh window to bind the sole in-progress task instead of asking.",
4
+ "breaking": false,
5
+ "recommendMigrate": false,
6
+ "changelog": "resolve_active_task() no longer treats a sole runtime session file as proof of ownership: allow_single_session_fallback defaults to False and only inject-subagent-context.py opts in, because copilot/gemini/qoder child agents cannot inherit a parent session id (mirrors upstream Trellis #608). Main sessions on every platform therefore see no active task until task.py start binds the window. The Pi extension now moves the previous session's pointer to the new session when session_start reports reason=new or fork with a previousSessionFile — the lineage Pi itself declares for /clear and fork — so a /clear in the same window keeps its task without counting pointer files or trusting key prefixes (#512 holds). Its startup <ready> block also tells a fresh window with exactly one implement/accept-stage task assigned to the developer to run task.py start for it rather than asking whether to create a task, and to ask when several qualify or the only candidate is still in planning.",
7
+ "migrations": [],
8
+ "notes": "No migration required. Changed files are all tracked by atw update: .atw/scripts/common/active_task.py, the shared inject-subagent-context.py hook under each platform directory, and .pi/extensions/atw/index.ts. Expect one explicit task.py start per new window on every platform from now on; a Pi /clear keeps the binding automatically."
9
+ }
@@ -648,17 +648,15 @@ def resolve_active_task(
648
648
  platform_input: dict[str, Any] | None = None,
649
649
  platform: str | None = None,
650
650
  *,
651
- allow_single_session_fallback: bool = True,
651
+ allow_single_session_fallback: bool = False,
652
652
  allow_environment_context: bool = True,
653
653
  ) -> ActiveTask:
654
654
  """Resolve the active task from session runtime state only.
655
655
 
656
- A stale session task is returned as stale. Missing context identity or a
657
- missing/empty session context falls back to single-session inference: if
658
- exactly one session file exists in the runtime, return its task with
659
- source_type="session-fallback" — covers pull-based platform sub-agents
660
- (copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
661
- files or 0 files yield ActiveTask(None) — refuses to guess across windows.
656
+ A stale session task is returned as stale.
657
+ Missing or unmatched session identity does not infer ownership from the
658
+ number of session files. Pull-based child-agent callers that cannot inherit
659
+ a parent identity must opt into the compatibility fallback explicitly.
662
660
  """
663
661
  context_key = resolve_context_key(
664
662
  platform_input,
@@ -1,4 +1,13 @@
1
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ openSync,
5
+ readFileSync,
6
+ readSync,
7
+ realpathSync,
8
+ renameSync,
9
+ statSync,
10
+ } from "node:fs";
2
11
  import { createHash, randomBytes } from "node:crypto";
3
12
  import {
4
13
  delimiter,
@@ -1055,17 +1064,19 @@ function parseAgentFM(c: string): AgentConfig {
1055
1064
  return cfg;
1056
1065
  }
1057
1066
 
1067
+ function keyForSessionId(sessionId: string): string {
1068
+ const normalized = sessionId.replace(/[^A-Za-z0-9._-]+/g, "_");
1069
+ if (!normalized) return `pi_${hash(sessionId)}`;
1070
+ return `pi_${normalized}${normalized === sessionId ? "" : `_${hash(sessionId)}`}`;
1071
+ }
1072
+
1058
1073
  function contextKey(input?: unknown, ctx?: PiExtensionContext): string | null {
1059
1074
  const sessionId =
1060
1075
  callStr(ctx?.sessionManager?.getSessionId, ctx?.sessionManager) ??
1061
1076
  str(process.env.PI_SESSION_ID) ??
1062
1077
  str(process.env.PI_SESSIONID) ??
1063
1078
  lookupStr(input, ["session_id", "sessionId", "sessionID"]);
1064
- if (sessionId) {
1065
- const normalized = sessionId.replace(/[^A-Za-z0-9._-]+/g, "_");
1066
- if (!normalized) return `pi_${hash(sessionId)}`;
1067
- return `pi_${normalized}${normalized === sessionId ? "" : `_${hash(sessionId)}`}`;
1068
- }
1079
+ if (sessionId) return keyForSessionId(sessionId);
1069
1080
  const transcriptPath =
1070
1081
  callStr(ctx?.sessionManager?.getSessionFile, ctx?.sessionManager) ??
1071
1082
  lookupStr(input, ["transcript_path", "transcriptPath", "transcript"]);
@@ -1117,6 +1128,55 @@ function readTaskDir(root: string, key: string | null): string | null {
1117
1128
  }
1118
1129
  }
1119
1130
 
1131
+ // ── Session lineage ───────────────────────────────────────────────────
1132
+ // 只读 pi 会话文件的首行头部拿会话 id;会话文件可能很大,不整读。
1133
+ function readSessionHeaderId(sessionFile: string): string | null {
1134
+ let fd: number | null = null;
1135
+ try {
1136
+ fd = openSync(sessionFile, "r");
1137
+ const buf = Buffer.alloc(65536);
1138
+ const n = readSync(fd, buf, 0, buf.length, 0);
1139
+ const line = buf.toString("utf-8", 0, n).split("\n")[0] ?? "";
1140
+ const header = JSON.parse(line) as JsonObject;
1141
+ return header.type === "session" ? str(header.id) : null;
1142
+ } catch {
1143
+ return null;
1144
+ } finally {
1145
+ if (fd !== null) closeSync(fd);
1146
+ }
1147
+ }
1148
+
1149
+ // 同一窗口用 /clear(reason=new)或 fork 换出新会话时,把前一个会话的活动任务指针
1150
+ // 搬到新会话名下。只认 pi 在 session_start 事件里给出的 previousSessionFile 这条
1151
+ // 明确血缘,不看运行时目录里有几个指针、也不看前缀(#512)。新开的窗口
1152
+ // (reason=startup)没有前身,什么都不继承。搬移而非复制,避免 /clear 链条堆积指针。
1153
+ function inheritSessionPointer(
1154
+ root: string,
1155
+ key: string | null,
1156
+ event: unknown,
1157
+ ): boolean {
1158
+ if (!key || !isObj(event)) return false;
1159
+ const reason = str(event.reason);
1160
+ const previous = str(event.previousSessionFile);
1161
+ if ((reason !== "new" && reason !== "fork") || !previous) return false;
1162
+ const parentId = readSessionHeaderId(previous);
1163
+ if (!parentId) return false;
1164
+ const parentKey = keyForSessionId(parentId);
1165
+ if (parentKey === key) return false;
1166
+ const dir = join(root, ".atw", ".runtime", "sessions");
1167
+ const ownPath = join(dir, `${key}.json`);
1168
+ const parentPath = join(dir, `${parentKey}.json`);
1169
+ try {
1170
+ if (existsSync(ownPath) || !existsSync(parentPath)) return false;
1171
+ const ctx = JSON.parse(readText(parentPath)) as JsonObject;
1172
+ if (!str(ctx.current_task)) return false;
1173
+ renameSync(parentPath, ownPath);
1174
+ return true;
1175
+ } catch {
1176
+ return false;
1177
+ }
1178
+ }
1179
+
1120
1180
  // ── Workflow State Breadcrumb ─────────────────────────────────────────
1121
1181
  const WF_RE =
1122
1182
  /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
@@ -1194,7 +1254,13 @@ function buildStartupContext(
1194
1254
  FIRST_REPLY_NOTICE,
1195
1255
  overview,
1196
1256
  workflow,
1197
- "<ready>\nUse the current workflow state to decide whether to create, continue, or skip an ATW task.\n</ready>",
1257
+ [
1258
+ "<ready>",
1259
+ "Use the current workflow state to decide whether to create, continue, or skip an ATW task.",
1260
+ "When CURRENT TASK is (none) but ACTIVE TASKS lists exactly one task assigned to you that is already at the implement or accept stage, this window is merely not bound to it yet: run `python3 ./.atw/scripts/task.py start <task-dir>` for that task, then continue from its stage. Do not ask whether to create a new task in that case.",
1261
+ "If several tasks qualify, or the only candidate is still in a planning stage (discover / specify / slice), ask which task to bind instead of running start: start moves a planning-stage task straight into implement.",
1262
+ "</ready>",
1263
+ ].join("\n"),
1198
1264
  ]
1199
1265
  .filter(Boolean)
1200
1266
  .join("\n\n");
@@ -1916,7 +1982,8 @@ export default function atwExtension(pi: {
1916
1982
 
1917
1983
  // Events
1918
1984
  pi.on?.("session_start", (event, ctx) => {
1919
- getKey(event, ctx);
1985
+ const k = getKey(event, ctx);
1986
+ inheritSessionPointer(resolveRoot(ctx), k, event);
1920
1987
  ctx?.ui?.notify?.(
1921
1988
  "ATW project context is available. Use /atw-start to bootstrap or /atw-continue to resume.",
1922
1989
  "info",
@@ -1093,7 +1093,11 @@ def main():
1093
1093
 
1094
1094
  # Get current task directory. May be None here — the AGENTS_REQUIRE_TASK
1095
1095
  # check below is what refuses to continue without one.
1096
- task_dir = get_current_task(repo_root, input_data)
1096
+ task_dir = get_current_task(
1097
+ repo_root,
1098
+ input_data,
1099
+ allow_single_session_fallback=True,
1100
+ )
1097
1101
 
1098
1102
  # implement/check need task directory
1099
1103
  if subagent_type in AGENTS_REQUIRE_TASK:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chinhae/atw-cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "A Team Workflow — an AI coding workflow framework that encodes your team's conventions into the process",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  "inquirer": "^9.3.7",
34
34
  "undici": "^6.21.0",
35
35
  "zod": "^4.4.2",
36
- "@chinhae/atw-core": "0.2.6"
36
+ "@chinhae/atw-core": "0.2.7"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^9.18.0",