@pennixrv/trellis 0.6.41 → 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.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"
@@ -498,8 +498,16 @@ export class TrellisContext {
498
498
  return []
499
499
  }
500
500
  })
501
- if (candidates.length !== 1) return null
502
- 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
+ }
503
511
  }
504
512
 
505
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
 
@@ -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,18 +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 when it contains a task reference; a missing or empty
658
- context may use the guarded unbound-task projection. When context identity
659
- is unavailable, single-session inference may cover pull-based platform
660
- sub-agents (copilot, gemini, qoder) that don't
661
- inherit the parent's session id. ≥2 files or 0 files yield
662
- 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.
663
663
  """
664
664
  context_key = resolve_context_key(
665
665
  platform_input,
@@ -672,10 +672,9 @@ def resolve_active_task(
672
672
  active = _active_from_ref(task_ref, repo_root, "session", context_key)
673
673
  if active:
674
674
  return active
675
- if allow_single_session_fallback:
676
- unbound = _resolve_unbound_task(repo_root)
677
- if unbound is not None:
678
- return unbound
675
+ unbound = _resolve_unbound_task(repo_root)
676
+ if unbound is not None:
677
+ return unbound
679
678
  return ActiveTask(None, "none", context_key)
680
679
 
681
680
  if allow_single_session_fallback:
@@ -683,9 +682,9 @@ def resolve_active_task(
683
682
  if fallback is not None:
684
683
  return fallback
685
684
 
686
- unbound = _resolve_unbound_task(repo_root)
687
- if unbound is not None:
688
- return unbound
685
+ unbound = _resolve_unbound_task(repo_root)
686
+ if unbound is not None:
687
+ return unbound
689
688
 
690
689
  return ActiveTask(None, "none", context_key)
691
690
 
@@ -735,11 +734,16 @@ def _resolve_unbound_task(repo_root: Path) -> ActiveTask | None:
735
734
  for task in iter_active_tasks(get_tasks_dir(repo_root))
736
735
  if task.assignee == developer and task.status in {"planning", "in_progress", "review"}
737
736
  ]
738
- if len(candidates) != 1:
737
+ if len(candidates) == 0:
739
738
  return None
740
739
 
741
- task_path = candidates[0].directory.relative_to(repo_root).as_posix()
742
- 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)
743
747
 
744
748
 
745
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
 
@@ -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.41",
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.41"
37
+ "@pennixrv/trellis-core": "0.6.43"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@eslint/js": "^9.18.0",