@pennixrv/trellis 0.6.40 → 0.6.43

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.6.41",
3
+ "description": "Restores active-task visibility when a host context key has no persisted session file.",
4
+ "breaking": false,
5
+ "recommendMigrate": false,
6
+ "changelog": "**Fix:**\n- Projects one unique developer-owned resumable task as read-only workflow context when the host provides a context key whose session file is missing, while still refusing to guess when another session pointer exists.",
7
+ "migrations": [],
8
+ "notes": "No project migration is required. Existing projects can run the normal trellis update flow."
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "0.6.42",
3
+ "description": "Isolates unbound task resolution from unrelated session fallback inference.",
4
+ "breaking": false,
5
+ "recommendMigrate": false,
6
+ "changelog": "**Fix:**\n- Stop the default task resolver from borrowing a task from a sole unrelated session file, while preserving read-only unbound task and ambiguity projections.",
7
+ "migrations": [],
8
+ "notes": "No project migration is required. Existing projects can run the normal trellis update flow."
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "0.6.43",
3
+ "description": "Ships the isolated active-task resolver to the dogfood script tree.",
4
+ "breaking": false,
5
+ "recommendMigrate": false,
6
+ "changelog": "**Fix:**\n- Keep the dogfood and published active-task resolver scripts synchronized so isolated session fallback behavior is identical in development and installed projects.",
7
+ "migrations": [],
8
+ "notes": "No project migration is required. Existing projects can run the normal trellis update flow."
9
+ }
@@ -229,6 +229,14 @@ def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path:
229
229
 
230
230
  def _get_task_status(trellis_dir: Path, hook_input: dict) -> str:
231
231
  active = _resolve_active_task(trellis_dir, hook_input)
232
+ if active.source_type == "unbound_ambiguous":
233
+ candidates = "\n".join(f"- {candidate}" for candidate in active.candidate_paths)
234
+ return (
235
+ "Status: TASK BINDING AMBIGUOUS\n"
236
+ f"Candidates:\n{candidates}\n"
237
+ "Next: Review the candidates and run `python3 ./.trellis/scripts/task.py start <task>` "
238
+ "with the intended task once a direct session identity is available."
239
+ )
232
240
  if not active.task_path:
233
241
  return (
234
242
  "Status: NO ACTIVE TASK\n"
@@ -383,7 +391,10 @@ def _build_compact_current_state(
383
391
  lines.append(_format_git_state(repo_root))
384
392
 
385
393
  active = _resolve_active_task(trellis_dir, hook_input)
386
- if active.task_path:
394
+ if active.source_type == "unbound_ambiguous":
395
+ candidates = ", ".join(active.candidate_paths)
396
+ lines.append(f"Current task: ambiguous; candidates={candidates}; bind explicitly.")
397
+ elif active.task_path:
387
398
  task_dir = _resolve_task_dir(trellis_dir, active.task_path)
388
399
  status = "unknown"
389
400
  task_json = task_dir / "task.json"
@@ -373,7 +373,8 @@ export class TrellisContext {
373
373
  *
374
374
  * Resolution order (mirrors Python `active_task.resolve_active_task`):
375
375
  * 1. Lookup the runtime file for the input-derived context key. A known
376
- * key is authoritative, so a missing or empty context returns no task.
376
+ * key is authoritative when it contains a task; a missing or empty
377
+ * context may use the guarded unbound-task projection.
377
378
  * 2. If no context key is available and exactly one session runtime file
378
379
  * exists locally, use it (`_resolveSingleSessionFallback`). Refuses to
379
380
  * guess when 0 or ≥2 files exist so multi-window isolation holds.
@@ -393,6 +394,10 @@ export class TrellisContext {
393
394
  stale: !taskDir || !existsSync(taskDir),
394
395
  }
395
396
  }
397
+ const unbound = this._resolveUnboundTask()
398
+ if (unbound) {
399
+ return unbound
400
+ }
396
401
  return { taskPath: null, source: "none", stale: false }
397
402
  }
398
403
 
@@ -456,7 +461,14 @@ export class TrellisContext {
456
461
  } catch {
457
462
  return null
458
463
  }
459
- if (sessionFiles.length > 0) return null
464
+ if (sessionFiles.some(file => {
465
+ try {
466
+ const context = JSON.parse(readFileSync(join(sessionsDir, file), "utf-8"))
467
+ return Boolean(this.normalizeTaskRef(context?.current_task || ""))
468
+ } catch {
469
+ return false
470
+ }
471
+ })) return null
460
472
  }
461
473
 
462
474
  const developer = (process.env.TRELLIS_DEVELOPER || "").trim() || (() => {
@@ -486,8 +498,16 @@ export class TrellisContext {
486
498
  return []
487
499
  }
488
500
  })
489
- if (candidates.length !== 1) return null
490
- return { taskPath: candidates[0], source: "unbound", stale: false }
501
+ if (candidates.length === 0) return null
502
+ if (candidates.length === 1) {
503
+ return { taskPath: candidates[0], source: "unbound", stale: false }
504
+ }
505
+ return {
506
+ taskPath: null,
507
+ source: "unbound_ambiguous",
508
+ stale: false,
509
+ candidatePaths: candidates.sort(),
510
+ }
491
511
  }
492
512
 
493
513
  getCurrentTask(platformInput = null) {
@@ -143,6 +143,14 @@ function loadBreadcrumbs(directory) {
143
143
  */
144
144
  function getActiveTask(ctx, platformInput = null) {
145
145
  const active = ctx.getActiveTask(platformInput)
146
+ if (active.source === "unbound_ambiguous") {
147
+ return {
148
+ id: null,
149
+ status: "unbound_ambiguous",
150
+ source: active.source,
151
+ candidates: active.candidatePaths || [],
152
+ }
153
+ }
146
154
  const taskRef = active.taskPath
147
155
  if (!taskRef) return null
148
156
  const taskDir = ctx.resolveTaskDir(taskRef)
@@ -170,12 +178,17 @@ function getActiveTask(ctx, platformInput = null) {
170
178
  * "Refer to workflow.md for current step." line
171
179
  * - no_task pseudo-status (id === null) → header omits task info
172
180
  */
173
- function buildBreadcrumb(id, status, templates) {
181
+ function buildBreadcrumb(id, status, templates, candidates = []) {
174
182
  let body = templates[status]
175
183
  if (body === undefined) {
176
184
  body = "Refer to workflow.md for current step."
177
185
  }
178
- let header = id === null ? `Status: ${status}` : `Task: ${id} (${status})`
186
+ let header
187
+ if (status === "unbound_ambiguous") {
188
+ header = `Status: ${status}\nCandidates: ${candidates.join(", ")}`
189
+ } else {
190
+ header = id === null ? `Status: ${status}` : `Task: ${id} (${status})`
191
+ }
179
192
  return `<workflow-state>\n${header}\n${body}\n</workflow-state>`
180
193
  }
181
194
 
@@ -218,7 +231,7 @@ export default async ({ directory }) => {
218
231
  const templates = loadBreadcrumbs(directory)
219
232
  const task = getActiveTask(ctx, platformInput)
220
233
  const breadcrumb = task
221
- ? buildBreadcrumb(task.id, task.status, templates, task.source)
234
+ ? buildBreadcrumb(task.id, task.status, templates, task.candidates)
222
235
  : buildBreadcrumb(null, "no_task", templates)
223
236
 
224
237
  prependEphemeralText(messages, breadcrumb)
@@ -149,7 +149,7 @@ def get_current_task(
149
149
  input_data: dict,
150
150
  *,
151
151
  platform: str | None = None,
152
- allow_single_session_fallback: bool = True,
152
+ allow_single_session_fallback: bool = False,
153
153
  allow_environment_context: bool = True,
154
154
  require_existing: bool = False,
155
155
  ) -> str | None:
@@ -740,7 +740,11 @@ def main():
740
740
  sys.exit(0)
741
741
 
742
742
  # Get current task directory (research doesn't require it)
743
- task_dir = get_current_task(repo_root, input_data)
743
+ task_dir = get_current_task(
744
+ repo_root,
745
+ input_data,
746
+ allow_single_session_fallback=True,
747
+ )
744
748
 
745
749
  # implement/check need task directory
746
750
  if subagent_type in AGENTS_REQUIRE_TASK:
@@ -169,6 +169,9 @@ def get_active_task(
169
169
  prompt.
170
170
  """
171
171
  active = _resolve_active_task(root, input_data)
172
+ if active.source_type == "unbound_ambiguous":
173
+ candidates = ", ".join(active.candidate_paths)
174
+ return candidates, "unbound_ambiguous", active.source
172
175
  if not active.task_path:
173
176
  return None
174
177
 
@@ -379,7 +382,10 @@ def build_breadcrumb(
379
382
  body = templates.get(status)
380
383
  if body is None:
381
384
  body = "Refer to workflow.md for current step."
382
- header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})"
385
+ if status == "unbound_ambiguous":
386
+ header = f"Status: {status}\nCandidates: {task_id}"
387
+ else:
388
+ header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})"
383
389
  return f"<workflow-state>\n{header}\n{body}\n</workflow-state>"
384
390
 
385
391
 
@@ -2,8 +2,8 @@
2
2
  """Session-scoped active task resolution.
3
3
 
4
4
  The user-facing concept is a single "active task". Trellis stores that pointer
5
- per AI session/window under `.trellis/.runtime/sessions/`; without a stable
6
- session key there is no active task.
5
+ per AI session/window under `.trellis/.runtime/sessions/`; when the pointer is
6
+ missing, a unique developer-owned resumable task may be exposed read-only.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -168,6 +168,7 @@ class ActiveTask:
168
168
  source_type: str
169
169
  context_key: str | None = None
170
170
  stale: bool = False
171
+ candidate_paths: tuple[str, ...] = ()
171
172
 
172
173
  @property
173
174
  def source(self) -> str:
@@ -648,17 +649,17 @@ def resolve_active_task(
648
649
  platform_input: dict[str, Any] | None = None,
649
650
  platform: str | None = None,
650
651
  *,
651
- allow_single_session_fallback: bool = True,
652
+ allow_single_session_fallback: bool = False,
652
653
  allow_environment_context: bool = True,
653
654
  ) -> ActiveTask:
654
655
  """Resolve the active task from session runtime state only.
655
656
 
656
- A stale session task is returned as stale. A known context identity is
657
- authoritative: a missing or empty context for that identity returns no
658
- task. When context identity is unavailable, single-session inference may
659
- cover pull-based platform sub-agents (copilot, gemini, qoder) that don't
660
- inherit the parent's session id. ≥2 files or 0 files yield
661
- ActiveTask(None) — refuses to guess across windows.
657
+ A stale session task is returned as stale. Missing or unmatched session
658
+ identity does not infer ownership from the number of session files.
659
+ A unique developer-owned task, or an explicit ambiguous projection, may
660
+ still be exposed without writing a binding. Only the legacy inference from
661
+ one unrelated session file is opt-in; pull-based child agents use that
662
+ compatibility opt-in when they cannot inherit a parent session identity.
662
663
  """
663
664
  context_key = resolve_context_key(
664
665
  platform_input,
@@ -671,6 +672,9 @@ def resolve_active_task(
671
672
  active = _active_from_ref(task_ref, repo_root, "session", context_key)
672
673
  if active:
673
674
  return active
675
+ unbound = _resolve_unbound_task(repo_root)
676
+ if unbound is not None:
677
+ return unbound
674
678
  return ActiveTask(None, "none", context_key)
675
679
 
676
680
  if allow_single_session_fallback:
@@ -678,9 +682,9 @@ def resolve_active_task(
678
682
  if fallback is not None:
679
683
  return fallback
680
684
 
681
- unbound = _resolve_unbound_task(repo_root)
682
- if unbound is not None:
683
- return unbound
685
+ unbound = _resolve_unbound_task(repo_root)
686
+ if unbound is not None:
687
+ return unbound
684
688
 
685
689
  return ActiveTask(None, "none", context_key)
686
690
 
@@ -713,8 +717,10 @@ def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
713
717
  def _resolve_unbound_task(repo_root: Path) -> ActiveTask | None:
714
718
  """Expose one developer-owned task when no session pointer exists."""
715
719
  sessions_dir = _runtime_sessions_dir(repo_root)
716
- if sessions_dir.is_dir() and any(sessions_dir.glob("*.json")):
717
- return None
720
+ if sessions_dir.is_dir():
721
+ session_files = sorted(sessions_dir.glob("*.json"))
722
+ if any(_string_value((_read_json(session) or {}).get("current_task")) for session in session_files):
723
+ return None
718
724
 
719
725
  from .paths import get_developer, get_tasks_dir
720
726
  from .tasks import iter_active_tasks
@@ -728,11 +734,16 @@ def _resolve_unbound_task(repo_root: Path) -> ActiveTask | None:
728
734
  for task in iter_active_tasks(get_tasks_dir(repo_root))
729
735
  if task.assignee == developer and task.status in {"planning", "in_progress", "review"}
730
736
  ]
731
- if len(candidates) != 1:
737
+ if len(candidates) == 0:
732
738
  return None
733
739
 
734
- task_path = candidates[0].directory.relative_to(repo_root).as_posix()
735
- return ActiveTask(task_path, "unbound", None)
740
+ task_paths = tuple(sorted(
741
+ task.directory.relative_to(repo_root).as_posix()
742
+ for task in candidates
743
+ ))
744
+ if len(task_paths) == 1:
745
+ return ActiveTask(task_paths[0], "unbound", None)
746
+ return ActiveTask(None, "unbound_ambiguous", None, candidate_paths=task_paths)
736
747
 
737
748
 
738
749
  def _utc_now() -> str:
@@ -125,7 +125,7 @@ def _file_digest(path: Path, maximum: int = MAX_EVIDENCE_BYTES) -> tuple[str, in
125
125
 
126
126
 
127
127
  def _task_source(root: Path) -> dict[str, Any]:
128
- active = resolve_active_task(root)
128
+ active = resolve_active_task(root, allow_single_session_fallback=True)
129
129
  if not active.task_path:
130
130
  raise NoDirectCurrentTask("no_direct_current_task")
131
131
  if active.source_type == "session-fallback":
@@ -312,6 +312,11 @@ def cmd_finish(args: argparse.Namespace) -> int:
312
312
  print(colored("Task exists but no direct session is bound; run task.py start first", Colors.YELLOW))
313
313
  print(f"Task: {current}")
314
314
  return 0
315
+ if active.source_type == "unbound_ambiguous":
316
+ print(colored("Multiple tasks exist but no direct session is bound; choose one before finishing", Colors.YELLOW))
317
+ for candidate in active.candidate_paths:
318
+ print(f"Candidate: {candidate}")
319
+ return 0
315
320
 
316
321
  try:
317
322
  assert_task_mutation_allowed(repo_root, repo_root / current)
@@ -367,6 +372,8 @@ def cmd_current(args: argparse.Namespace) -> int:
367
372
  "source": active.source,
368
373
  "stale": active.stale,
369
374
  }
375
+ if active.candidate_paths:
376
+ payload["candidates"] = list(active.candidate_paths)
370
377
  # Only present when the read failed, so the healthy shape is unchanged.
371
378
  if read_error:
372
379
  payload["error"] = read_error
@@ -374,6 +381,12 @@ def cmd_current(args: argparse.Namespace) -> int:
374
381
  return 0 if active.task_path else 1
375
382
 
376
383
  if args.source:
384
+ if active.source_type == "unbound_ambiguous":
385
+ print("Current task: (ambiguous)")
386
+ print("Source: unbound_ambiguous")
387
+ for candidate in active.candidate_paths:
388
+ print(f"Candidate: {candidate}")
389
+ return 1
377
390
  print(f"Current task: {active.task_path or '(none)'}")
378
391
  print(f"Source: {active.source}")
379
392
  if active.stale:
@@ -384,6 +397,11 @@ def cmd_current(args: argparse.Namespace) -> int:
384
397
  print(active.task_path)
385
398
  return 0
386
399
 
400
+ if active.source_type == "unbound_ambiguous":
401
+ print("Multiple active tasks require explicit binding:")
402
+ for candidate in active.candidate_paths:
403
+ print(candidate)
404
+
387
405
  return 1
388
406
 
389
407
 
@@ -76,7 +76,7 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run]
76
76
 
77
77
  > Run `python3 ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list.
78
78
 
79
- **Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task.
79
+ **Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, `task.py start` fails with a session identity hint because it cannot persist a session pointer; read-only context may still project one unique developer-owned resumable task. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task.
80
80
 
81
81
  ### Workspace System
82
82
 
@@ -123,6 +123,8 @@ python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed
123
123
  TAG ↔ PHASE scoping:
124
124
  [workflow-state:no_task] → no active task; before Phase 1
125
125
  [workflow-state:unbound_task] → one developer-owned task exists without a session pointer
126
+ [workflow-state:unbound_ambiguous] → multiple developer-owned tasks exist without a session pointer
127
+ [workflow-state:unbound_ambiguous-inline] → Codex inline variant of unbound_ambiguous
126
128
  [workflow-state:task_error] → active task record is unreadable; repair it before continuing
127
129
  [workflow-state:planning] → all of Phase 1 (status='planning')
128
130
  [workflow-state:planning-inline] → Codex inline variant of Phase 1
@@ -196,6 +198,16 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
196
198
  An existing task is assigned to the current developer, but this shell has no direct Trellis session binding. Do not create a duplicate task. Continue reading and working from the existing task artifacts; before any lifecycle write or closure, run the native `task.py start <task>` command once a direct session identity is available. Never edit `.trellis/.runtime/sessions/` manually.
197
199
  [/workflow-state:unbound_task]
198
200
 
201
+ <!-- Per-turn breadcrumb shown when multiple resumable tasks exist without a direct session binding. -->
202
+
203
+ [workflow-state:unbound_ambiguous]
204
+ Multiple active tasks belong to the current developer, but this shell has no direct Trellis session binding. Do not guess or create a duplicate task. Review the listed candidates and run `python3 ./.trellis/scripts/task.py start <task>` with the intended task once a direct session identity is available.
205
+ [/workflow-state:unbound_ambiguous]
206
+
207
+ [workflow-state:unbound_ambiguous-inline]
208
+ Multiple active tasks belong to the current developer, but this Codex session has no direct Trellis session binding. Do not guess or create a duplicate task. Review the listed candidates and run `python3 ./.trellis/scripts/task.py start <task>` with the intended task once a direct session identity is available.
209
+ [/workflow-state:unbound_ambiguous-inline]
210
+
199
211
  <!-- Per-turn breadcrumb: shown when the active task record cannot be read. -->
200
212
 
201
213
  [workflow-state:task_error]
@@ -702,6 +714,7 @@ All tag blocks live in the `## Phase Index` section above, immediately after eac
702
714
  |---|---|
703
715
  | No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
704
716
  | One developer-owned task without a session binding | `[workflow-state:unbound_task]` (bind before lifecycle writes) |
717
+ | Multiple developer-owned tasks without a session binding | `[workflow-state:unbound_ambiguous]` / `[workflow-state:unbound_ambiguous-inline]` (choose and bind explicitly) |
705
718
  | Active task record unreadable | `[workflow-state:task_error]` (repair the existing task before continuing) |
706
719
  | All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
707
720
  | Codex inline Phase 1 | `[workflow-state:planning-inline]` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pennixrv/trellis",
3
- "version": "0.6.40",
3
+ "version": "0.6.43",
4
4
  "description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "inquirer": "^9.3.7",
35
35
  "undici": "^6.21.0",
36
36
  "zod": "^4.4.2",
37
- "@pennixrv/trellis-core": "0.6.40"
37
+ "@pennixrv/trellis-core": "0.6.43"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@eslint/js": "^9.18.0",