From d428defec0aee695a4c71a6cea07040684621df5 Mon Sep 17 00:00:00 2001 From: John Preston Date: Sun, 23 Aug 2026 10:20:43 +0400 Subject: [PATCH] [ai] Route oversized tasks through durable splits --- .agents/skills/continue/SKILL.md | 133 ++- .../references/split-required-task.md | 111 +++ .agents/skills/perform-task/SKILL.md | 26 +- .../perform-task/references/phase-prompts.md | 8 + .../perform-task/references/pipeline.md | 64 +- .agents/skills/process-inbox/SKILL.md | 19 +- .../skills/process-inbox/scripts/workspace.py | 898 +++++++++++++++++- .../process-inbox/scripts/workspace_test.py | 220 +++++ .claude/ai-workflow-adapter.md | 14 +- .claude/commands/continue.md | 16 +- .claude/commands/perform-task.md | 3 + .gitignore | 2 + .grok/ai-workflow-adapter.md | 15 +- .grok/commands/continue.md | 14 +- .grok/commands/perform-task.md | 3 + 15 files changed, 1412 insertions(+), 134 deletions(-) create mode 100644 .agents/skills/continue/references/split-required-task.md diff --git a/.agents/skills/continue/SKILL.md b/.agents/skills/continue/SKILL.md index 59c5362db8..c73dc88ed9 100644 --- a/.agents/skills/continue/SKILL.md +++ b/.agents/skills/continue/SKILL.md @@ -52,14 +52,21 @@ The canonical lifecycle is deliberately small: - `todo` with `claimed_by: null` is ready shared work; - `in-progress` with this `checkout_tag` is this checkout's one active task; - `blocked` with this tag is a rare published unfinished boundary; +- `split-required` with this tag is a published performer result awaiting one + scheduler-owned split transaction; - `approved` is the only completed terminal state. +After routing, the retired source has no live state: its sealed `split.yaml` +records all successors and the optional implementation carrier. This is +terminal history, not another runnable status. + `Start` atomically assigns an unclaimed task and changes it to `in-progress`. Normal phase artifacts remain local and uncommitted in the slot worktree. `Approve` publishes all final AI artifacts and state in one commit. `Block` is permitted only for a genuine exhausted task blocker, -not for an interrupted agent session. Never publish `Claim`, phase checkpoint, -or `Resume` commits. Existing claimed `todo` records from the older workflow +not for an interrupted agent session. `Split-required` publishes the proposal +and seals any retained source work for the scheduler transaction. Never publish +`Claim`, phase checkpoint, or `Resume` commits. Existing claimed `todo` records from the older workflow remain startable but do not justify creating new reservations. Never infer ownership from an inbox receipt. Do not steal work from another @@ -76,8 +83,18 @@ the queue. A repeated pre-commit race may remain pending for the next invocation; record that marker as attempted and do not spin. If a task is already active, its expected local phase files make the shared AI slot unsafe for consolidation: freeze and finish that active task first, then recover all -pending markers at its clean canonical `Approve` or `Block` boundary before -selecting more work. +pending markers at its clean canonical `Approve`, `Block`, or completed split +routing boundary before selecting more work. + +After recovering pending consolidations and before freezing a new batch, route +this checkout's `own_split_required` task, when any, through the dedicated split +worker below. Refresh the queue afterward and freeze the resulting replacement +tasks at the front of the initial batch regardless of a scope hint; they replace +checkout work that hints cannot exclude. Record that startup split for the +invocation summary. A split-required task is exclusive checkout work: do not +start, retry, or resume another task while it remains unrouted. If its retained +source state cannot be transferred safely, stop with that state intact rather +than skipping it. ## Interpret scope hints @@ -87,7 +104,8 @@ preference such as "payments tasks first" reorders the shared tasks recorded in the startup batch; it does not exclude the others. A restriction such as "only the payments tasks" or "all tasks except projects X and Y" records only matching unclaimed shared tasks. -Hints never exclude this checkout's active, blocked, or legacy-reserved work. +Hints never exclude this checkout's active, split-required, blocked, or +legacy-reserved work. A restrictive hint that matches no shared task does not make an existing queue look idle or permit inbox processing. @@ -113,7 +131,9 @@ these invocation-local values in the scheduler plan: - ordered `batch_task_ids`, initially equal to the initial batch; - empty `discovered_task_ids`; - empty `consolidation_mappings` and `consolidation_receipts`; -- empty `attempted_blocked`. +- empty `attempted_blocked`; +- `split_records`, initialized with any startup split transaction and otherwise + empty. Do not write a batch file, claim the whole batch, or publish reservations. Queue refreshes update task state but never add ordinary task ids to the @@ -170,14 +190,15 @@ would record from the startup snapshot: - only matching unclaimed tasks under a restrictive hint. Record dependency-waiting tasks too. Finish the active task to an approved, -genuinely blocked, or global-hard-stop boundary, then continue through ready -recorded tasks one at a time. Only an explicit request to run just the active +genuinely blocked, split-required, or global-hard-stop boundary, route any +split before continuing, then continue through ready recorded tasks one at a +time. Only an explicit request to run just the active task produces a one-item active batch. ### Mode 2: drain the existing queue snapshot -When there is no active task but any `own_blocked`, `own_todo`, or -`unclaimed_todo` task exists, choose `queue` mode. Record: +When there is no active or split-required task but any `own_blocked`, +`own_todo`, or `unclaimed_todo` task exists, choose `queue` mode. Record: - every own blocked and legacy-reserved task, regardless of the hint; - every unclaimed task under no hint or a preference, ordered with preferred @@ -194,8 +215,9 @@ recorded batch drains. ### Mode 3: bootstrap from the inbox -Choose `inbox` mode only when `own_in_progress`, `own_blocked`, `own_todo`, -and `unclaimed_todo` were all empty in the initial snapshot. Work owned by +Choose `inbox` mode only when `own_in_progress`, `own_split_required`, +`own_blocked`, `own_todo`, and `unclaimed_todo` were all empty in the initial +snapshot. Work owned by another checkout is not work this checkout can drain and does not enter its batch. @@ -226,8 +248,12 @@ claimed concurrently, blocked by an external dependency, or otherwise unavailable. Before publishing any new canonical `Start` commit, require a clean Telegram -source checkout with clean submodules and no unrelated untracked files. Do not -require a Telegram executable, portable account, desktop, Docker daemon, or +source checkout with clean submodules and no unrelated untracked files. The one +exception is the checkout-owned first replacement whose `carried_from` field +and source `split.yaml` designate it as the implementation carrier: start it +with the retained source state intact, and let the helper revalidate the sealed +worktree before transferring task refs. Do not require a Telegram executable, +portable account, desktop, Docker daemon, or other instrument before assessment selects it. The performer gates every selected instrument before using it and records an unavailable platform or stage precisely instead of preventing unrelated task work from starting. @@ -240,7 +266,18 @@ resumption handoff. There must be at most one active task. Stop on an active task outside the batch instead of silently expanding the batch or stealing ownership. -### 2. Retry recorded blocked work +### 2. Start a carried implementation + +Otherwise select the first ready checkout-owned `todo` task in +`batch_task_ids` whose `carried_from` field names a retired split task. It must +be the first replacement and match that task's `implementation_carrier`. +Start it with the normal helper command. The helper verifies the source +worktree seal, transfers the old task's base/green/run refs, publishes the +carrier's canonical `Start`, and removes the obsolete refs. If any check fails, +hard-stop with both the carrier and retained source state intact; do not start +another batch task around it. + +### 3. Retry recorded blocked work Otherwise select the first ready task in `own_blocked` whose id is in `batch_task_ids` and not in `attempted_blocked`. Readiness means every @@ -261,7 +298,7 @@ genuine new `Block` boundary under the validation below. A test-campaign cap, `TEST_FLAW`, blank/missing evidence, or another recoverable harness failure is not genuine and does not consume this invocation's blocked retry. -### 3. Start recorded reserved work +### 4. Start recorded reserved work Otherwise select the first ready legacy `todo` task already owned by this checkout whose id is in `batch_task_ids`, and start it: @@ -275,7 +312,7 @@ The resulting canonical `Start` commit changes it to `in-progress`. Leave legacy reservations with unfinished dependencies untouched and consider later ready batch work. -### 4. Start recorded shared work +### 5. Start recorded shared work Otherwise select the first ready unclaimed `todo` task whose id is in `batch_task_ids`, using the order recorded at startup. Start it with the same @@ -286,11 +323,12 @@ A concurrent start may mean another checkout won the task. Never overwrite shared state or replace it with a task outside the batch; refresh and continue with another recorded id. -### 5. Stop normally +### 6. Stop normally Stop when none of these batch-scoped conditions exist: - this checkout's active batch task; +- a ready recorded carried implementation; - a ready recorded blocked task not attempted in this invocation; - a ready recorded legacy-reserved task; - a ready recorded unclaimed task. @@ -327,9 +365,9 @@ Source checkout: AI slot worktree: Checkout tag: Task: -Own this task until it is approved, genuinely blocked, or reaches a global -hard stop. You may use the bounded leaf delegation required by the skill. Do -not select or start another task. +Own this task until it is approved, genuinely blocked, split-required, or +reaches a global hard stop. You may use the bounded leaf delegation required by +the skill. Do not select or start another task. ``` The performer is stateful. Never duplicate it. Poll at no more than 60-second @@ -342,23 +380,42 @@ After it returns, require one of: canonical AI master; - source checkout clean and task exceptionally `blocked` on canonical master, with exact unverified behavior; -- a pre-source `Scope: split-required` or review-convergence - `RESCOPE_REQUIRED` boundary with `work/split-proposal.md`, leaving the task - `in-progress` and all source/task recovery intact; or +- task `split-required` on canonical AI master with `work/split-proposal.md` + and `work/carried-work.json`, leaving every retained source change and task + ref sealed for transfer; or - a clearly reported global hard stop, leaving the task `in-progress` and all task-scoped local state recoverable for the next invocation. -A rescope boundary stops task performance and is not retried, approved, blocked, -or routed as an ordinary discovered follow-up. The scheduler is the queue -authority: it independently checks that the proposal contains separately -shippable/testable boundaries and a coherent dependency graph before any -dedicated rescope transaction or human handoff. It never treats the performer's -proposal alone as permission to delete source or rewrite shared task state. On -a later invocation, a performer that finds the same unresolved boundary returns -it immediately without rerunning planning, review, builds, or tests. Inbox -planning prevents most new oversized tasks; replacing an already-active task -and deciding how to salvage its source requires that explicit scheduler-owned -transaction, not ordinary discovery routing. +A rescope result stops task performance and is not retried, approved, blocked, +or routed as an ordinary discovered follow-up. The performer's publication is +the durable request for the scheduler-owned transaction below; it is not +permission to delete source or flatten several successors into one alias. + +## Route a split-required result + +When a batch performer publishes `split-required`, immediately spawn one fresh +split worker with `fork_turns: "none"`. Give it `source_root`, `slot_worktree`, +`checkout_tag`, the source task id, and the current ordered batch. Tell it not +to delegate and to read +`.agents/skills/continue/references/split-required-task.md` completely. It may +inspect Telegram source and edit/publish AI task, project, dependency, and +receipt state; it must not edit, reset, stash, commit, build, or test Telegram +source. + +Validate its canonical `Split ` result and refreshed queue. Replace +the source id in `batch_task_ids` at its existing position with the ordered +replacement ids, removing duplicates; leave `initial_batch_task_ids` unchanged. +Append the source, replacements, carrier, and receipt to `split_records`. The +replacements are part of this invocation because they replace an existing batch +member, not because later queue refreshes normally expand the frozen batch. + +When a carrier exists it must be first, checkout-owned `todo`, and name the +source in `carried_from`; select it through the carried-implementation step +before any blocked, reserved, or shared task. Starting it performs the sealed +source-ref transfer. When no carrier exists, all replacements are ordinary +unclaimed `todo`. A split publication race is retried normally. A semantic +conflict, unavailable remote, changed worktree seal, or incoherent carrier is a +global hard stop with the source result and implementation left recoverable. Before accepting a canonical test block, read `work/result.md` and `work/test.md`. It is genuine only when the verdict is not `TEST_FLAW`, does @@ -543,7 +600,8 @@ this invocation, and continue without treating the optimization as a blocker. If the worker created a commit that cannot be published safely, preserve it and hard-stop exactly as for discovery routing. -At every clean canonical `Approve` or `Block` boundary, process any older +At every clean canonical `Approve`, `Block`, or completed split routing +boundary, process any older pending marker deferred by an active startup task before selecting more work, then process the marker just created by that task's routing. Attempt each marker at most once per invocation. @@ -562,7 +620,8 @@ Return one compact summary: invocation mode, initial batch ids, discovered ids added to the batch, inbox receipt if processed, tasks approved, exceptionally blocked tasks with exact unverified behavior and retry status, recorded tasks left queued, unrelated new tasks deferred to the next invocation, routed -discoveries, infrastructure-limited coverage gaps recorded but not routed, +discoveries, split-required sources with ordered replacements and carriers, +infrastructure-limited coverage gaps recorded but not routed, consolidation no-merge results or receipts, old-to-new mappings, the net task-count saving, archived projects, any discarded interrupted-worker leftovers, elapsed time, and why the loop stopped. Make any global hard stop or unsafe diff --git a/.agents/skills/continue/references/split-required-task.md b/.agents/skills/continue/references/split-required-task.md new file mode 100644 index 0000000000..552ad09b2f --- /dev/null +++ b/.agents/skills/continue/references/split-required-task.md @@ -0,0 +1,111 @@ +# Route a split-required task + +Run this only from the `continue` scheduler for one task already published as +`status: split-required`. It is a deep task-routing transaction, not task +implementation. Inspect Telegram source and the retained implementation, but +do not edit, reset, stash, commit, build, or test Telegram source. + +## Read the retained task + +Read the source task's complete `task.md`, project context, dependencies, +`work/context.md`, `work/plan.md`, `work/split-proposal.md`, `work/result.md`, +and `work/carried-work.json`. Inspect the complete retained source diff and +the relevant adjacent code. The proposal is input, not a set of titles to copy +blindly: refine it into the smallest independently shippable and independently +testable product boundaries. + +Each replacement must have one useful outcome, a direct acceptance oracle, and +enough self-contained context to run without this routing session. Split at +network, persistence, engine, ownership/lifetime, lifecycle, UI, platform, or +evidence boundaries when they can stand on their own. Keep inseparable APIs and +their only callers together. Add a final integration task only when integration +has behavior not already proved by the component tasks. + +Preserve the source task's project by default. A replacement may leave that +project only when it remains coherent, implementable, and testable with the +project changes absent. Preserve the source task's existing dependencies where +their code is still required. Order replacement dependencies by actual shipped +code or behavior, not by the order in which the plan happened to mention them. + +## Preserve existing implementation + +`work/carried-work.json` records whether source implementation exists and seals +the owned working diff. When it says `implementation: retained`, designate +exactly one first replacement as the implementation carrier. Shape that task so +the whole retained diff is inside a coherent boundary it can inspect, correct, +review, build, and test. If the diff spans the future slices, a real first task +that stabilizes and proves the shared foundation is appropriate; an unreviewed +checkpoint or a task whose only result is storing the patch is not. + +The carrier's `task.md` must name the split source task, tell the performer to +read its retained artifacts and complete source diff, and state which current +implementation it adopts. Later replacements depend on the carrier whenever +they consume that code. Create the carrier initially as pristine unclaimed +`todo`; the publication helper transfers its reservation and +`owned-paths.txt`. Do not copy phase conclusions as approvals: the carrier runs +its own focused planning, complete review, and evidence loop over the retained +implementation. + +Do not discard existing implementation merely because a cleaner decomposition +would have started differently. Do not reset it, hide it in an ignored patch, +or split it into unreviewed commits during routing. If no coherent carrier can +own it, stop with the split-required task and source state untouched and report +the exact conflict for a human decision. + +When `implementation: none`, create no carrier and leave every replacement +unclaimed. + +## Write replacement state + +Create at least two dated replacement task directories using the ordinary +`process-inbox` task and state schemas. Every replacement uses `type: implement` +and names the split receipt in `inbox_receipt`. Before publication every state +is pristine unclaimed `todo` and omits or sets `carried_from: null`; the helper +sets the carrier's durable ownership transfer. + +Write one receipt under `receipts/YYYY/MM/DD/` containing: + +- the split source id and why one campaign was incoherent; +- every replacement id, shipped boundary, acceptance oracle, and dependency; +- the implementation carrier or `none`; +- how every original acceptance criterion and retained source path is owned; +- every changed dependent and project link. + +Replace the source task's link in its project index with the replacement links +in dependency/routing order. Rewrite every live task dependency on the source +to the exact successor or successors that provide the needed behavior. A +retired split id must remain in neither a live dependency nor a project index. +Do not edit the source task directory; the helper replaces only its +`state.yaml` with a sealed multi-target `split.yaml`. + +## Validate and publish + +Check the complete dependency graph, task links, projects, receipt, native text +format, and absence of persisted commit hashes. Then publish through: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py split-publish \ + --source-task \ + --receipt \ + --replacement \ + --replacement \ + [--implementation-carrier ] \ + --path \ + --path \ + --path \ + --path +``` + +Use the host's available Python 3 command. The helper rechecks the sealed source +worktree, replacement state, receipt, project indexes, full dependency graph, +and retained source-task digest. It publishes `Split `. With a +carrier, it leaves the source refs and working implementation intact and makes +the carrier a checkout-owned `todo`; the scheduler must select it first and use +the normal `start` command, which rechecks the worktree seal, transfers the refs, +and publishes the carrier's canonical `Start`. Without a carrier the helper +removes the obsolete source refs after publication. + +On an ordinary publication race, retry through the helper. Preserve an +unpublished split commit on semantic conflict or remote outage. Report the +source id, ordered replacements, carrier, receipt, publication state, and any +hard stop; never report commit hashes. diff --git a/.agents/skills/perform-task/SKILL.md b/.agents/skills/perform-task/SKILL.md index 1ef99b2b1f..6339512169 100644 --- a/.agents/skills/perform-task/SKILL.md +++ b/.agents/skills/perform-task/SKILL.md @@ -1,6 +1,6 @@ --- name: perform-task -description: Resolve, start or resume, implement, review, test, and publish exactly one existing ai-tdesktop task by short slug or full dated id, including rare blocked unfinished work. Use when the user invokes $perform-task or /perform-task with a known task name, or when the continue scheduler delegates one selected task. Runs standard review lenses with fast applicability bailouts and selects task-specific domain and evidence instruments without selecting additional work. +description: Resolve, start or resume, implement, review, test, and publish exactly one existing ai-tdesktop task by short slug or full dated id, including rare blocked retries and split-required results. Use when the user invokes $perform-task or /perform-task with a known task name, or when the continue scheduler delegates one selected task. Runs standard review lenses with fast applicability bailouts and selects task-specific domain and evidence instruments without selecting additional work. --- # Perform One AI Task @@ -10,7 +10,8 @@ before any other host-specific delegation rule and apply its substitutions. Own exactly one task through its retained change, or a proved `already-satisfied` outcome, and a canonical AI `Approve` or exceptional -`Block`. Do not process the inbox, split the task, drain the queue, +`Block`, or a canonical `Split-required` result. Do not process the inbox, +create replacement tasks, drain the queue, select a follow-up, or consolidate pending tasks afterward. The `continue` scheduler isolates discovery routing and queue consolidation in fresh workers after this performer returns. @@ -62,6 +63,9 @@ Inspect the resolved task, readiness, `other_active_task`, status, and owner. - If another task is already `in-progress` for this checkout, stop. - If this task is `approved`, report its completed result and stop. +- If it is `split-required`, report its published split proposal and stop. A + direct invocation leaves routing to the human; a scheduler invocation returns + control so `continue` can launch the dedicated split worker. - If it is owned by another checkout, stop. Cross-checkout restart is a rare explicit human reassignment, never an implicit steal. - If its dependencies are unfinished, report them and stop without starting. @@ -109,7 +113,7 @@ the scheduler may continue work that does not depend on it. ## Run and publish Execute `references/pipeline.md` exactly. A task that changes the repository -produces: +and is approved produces: 1. one or more tested source implementation-attempt commits, each with an exact one-line subject using the pipeline's conditional `[ai] ` prefix, @@ -123,12 +127,16 @@ New and unfinished tasks use the single adaptive `implement` path. Assessment must first confirm that the request is one cohesive implementation/review/test unit. If it contains independently useful and independently testable product boundaries, record `Scope: split-required` and a concrete split proposal before -source edits, then stop for queue rescoping; do not force the broad request -through smaller implementation phases and call it one task. The independent -assessment has veto authority over implementation, not authority to create, -retire, or rewrite tasks. The performer validates and preserves the proposal; -the checkout scheduler owns any later queue mutation. A direct invocation -returns the proposal to the human. +source edits. The same result may arise later from the bounded convergence +assessment when the retained implementation proves that one review/evidence +campaign is not coherent. Do not force the broad request through smaller +implementation phases and call it one task. The independent assessment has +veto authority over further implementation, not authority to create, retire, +or rewrite tasks. The performer writes the split result, preserves any owned +implementation and source refs, and publishes it with +`finish --status split-required`. The checkout scheduler owns the later queue +mutation and implementation transfer. A direct invocation returns the +published proposal to the human. For a cohesive task, use one mandatory general review, all five standard review lenses, and a falsifiable evidence plan. On the initial implementation the diff --git a/.agents/skills/perform-task/references/phase-prompts.md b/.agents/skills/perform-task/references/phase-prompts.md index 4f40cfba53..4ea07326ed 100644 --- a/.agents/skills/perform-task/references/phase-prompts.md +++ b/.agents/skills/perform-task/references/phase-prompts.md @@ -350,6 +350,9 @@ independent assessor has veto authority because it is the first phase with the exact implementation and evidence plan. It does not create, retire, supersede, or rewrite queue tasks; the performer preserves the proposal and returns it to the scheduler, which owns any rescope transaction. +The performer does not leave this as an unpublished `in-progress` marker: after +validating the proposal it writes the split result and calls +`finish --status split-required`, preserving any owned source state. ```text You are a plan assessment agent. Review and refine an implementation plan. @@ -908,6 +911,11 @@ blocking findings, stop with its unapproved artifacts; do not begin another campaign automatically. ~~~ +On `RESCOPE_REQUIRED`, the performer validates `split-proposal.md`, inventories +all retained owned source paths, writes the canonical split result, and calls +`finish --status split-required`. It does not clean or checkpoint source work; +the scheduler's later split worker assigns the sealed implementation carrier. + ### Review fix ~~~text diff --git a/.agents/skills/perform-task/references/pipeline.md b/.agents/skills/perform-task/references/pipeline.md index dc68cbe5f4..d9bf9765ac 100644 --- a/.agents/skills/perform-task/references/pipeline.md +++ b/.agents/skills/perform-task/references/pipeline.md @@ -277,9 +277,12 @@ Run sequentially: approach rejection with the assessor's simpler direction as added input — or the terminal pre-edit `Scope: split-required` planning boundary. This assessor has authority to veto source implementation because it has the - first exact implementation/evidence plan. It cannot mutate task state beyond - its local artifacts or create replacement tasks; the scheduler independently - validates and owns rescoping. + first exact implementation/evidence plan. It cannot create replacement tasks; + the performer publishes the proposal as the task's `split-required` result, + and the scheduler independently owns the deep split transaction. The same + result may be produced after source edits by the bounded convergence + assessment when the retained implementation exposes several independently + shippable/testable boundaries. 3. **Implement.** Run one leaf per assessed plan phase. Before each edit, update `work/owned-paths.txt`. A leaf edits only its owned paths and its phase status; it does not commit. @@ -718,14 +721,22 @@ behavior. `Blocker-Type: test` additionally requires `work/test.md` to contain Computer Use infrastructure-unavailable case. A `TEST_FLAW`, a run cap, or a missing capture can never be the blocked verdict. +For `split-required`, do not clean, reset, stash, or checkpoint retained source +implementation. Require current task base/run refs, keep the owned diff and any +green commit in place, and inventory every owned path in `work/owned-paths.txt`. +The publication helper seals that work in `work/carried-work.json`; the later +split transaction rechecks the seal before assigning an implementation carrier. + Write `work/result.md` with exactly one value for every field: ```text # Task result: -STATUS: DONE | BLOCKED -Outcome: changed | already-satisfied | blocked -Verdict: APPROVED | +STATUS: DONE | BLOCKED | SPLIT_REQUIRED +Outcome: changed | already-satisfied | blocked | split-required +Verdict: APPROVED | SPLIT_REQUIRED | Blocker-Type: none | test | impl | unrecoverable +Implementation: retained | none +Split-Proposal: work/split-proposal.md | none Attempts: Test-Runs: UI-Driver: overlay | hybrid | mixed | hybrid-unavailable | not-applicable @@ -733,7 +744,7 @@ Touched: Test-Report: work/test.md Evidence: Unverified: none | -Checkout: clean-buildable | unsafe +Checkout: clean-buildable | source-state-retained | unsafe Discovered: none | present ## Discovered tasks @@ -746,6 +757,14 @@ source commit, and direct evidence that the requested proposition held before the task. A blocked result uses `Outcome: blocked` and retains a latest safe implementation attempt when one exists. +A split result uses `STATUS: SPLIT_REQUIRED`, `Outcome: split-required`, +`Verdict: SPLIT_REQUIRED`, `Blocker-Type: none`, and +`Split-Proposal: work/split-proposal.md`. Set `Implementation: retained` and +name its paths under `Touched:` when owned source work exists; otherwise use +`Implementation: none` and `Touched: none`. It uses +`Checkout: source-state-retained` and does not require a test report, because +each replacement receives its own complete review and evidence campaign. + For approved project work, promote `work/project.proposed.md` to the project's `project.md` immediately before final AI publication. For blocked work, retain the proposal only as a task artifact. @@ -755,7 +774,7 @@ Publish final AI state only after the Telegram commit and result are final: ```bash python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \ finish --source-root SOURCE_ROOT --task TASK_ID \ - --status approved|blocked --model MODEL_SHORT_NAME + --status approved|blocked|split-required --model MODEL_SHORT_NAME ``` `--model` is required and records which model finished the task, into the @@ -768,14 +787,17 @@ resumed by a different model after an interruption records the model that actually completed it. Never guess or copy the value from another task; if you cannot tell what you are, say so and stop rather than recording a wrong name. -The helper verifies a clean source checkout, local task refs, current `HEAD`, -and either the retained implementation's exact three-line commit message or -the strict no-change state required by `already-satisfied`. It commits -all task-scoped local artifacts and final state as `Approve ` or the -exceptional `Block `, fetches newer canonical state when configured, +For approval or Block, the helper verifies a clean source checkout, local task +refs, current `HEAD`, and either the retained implementation's exact three-line +commit message or the strict no-change state required by `already-satisfied`. +For split-required it verifies current refs, seals the owned working state, and +does not require a clean source checkout. It commits all task-scoped local +artifacts and final state as `Approve `, exceptional +`Block `, or `Split-required `, fetches newer canonical state when configured, rebases the slot, publishes without force, and fast-forwards local AI master. It deletes all local task refs after approval; after a block it deletes only -`RUN_REF` and retains implementation recovery refs for the next invocation. +`RUN_REF` and retains implementation recovery refs for the next invocation; +after split-required it retains all refs for the scheduler's carrier transfer. Do not report final state until that AI commit reaches canonical master. Preserve an unpublished final slot commit on a semantic conflict or remote outage and hard-stop instead of pretending completion. @@ -825,13 +847,13 @@ delays finishing the work actually in hand. - `Scope: split-required` before source edits and `RESCOPE_REQUIRED` from a convergence assessment are planning boundaries, not task `Block` verdicts - and not permission to keep retrying. Preserve `split-proposal.md`, all - validated source recovery, and the task's `in-progress` state; return the - proposal to the scheduler (or human for a direct invocation) and stop - automatic performance until the queue is deliberately replaced. Assessment - owns the stop; only the scheduler owns task creation, dependency rewrites, - and superseding the original. A resumed performer that sees the same - unresolved boundary reports it immediately without rerunning context, + and not permission to keep retrying. Preserve `split-proposal.md`, all owned + implementation and source recovery, write the split result, and publish + `finish --status split-required`; then return control to the scheduler (or + human for a direct invocation). Assessment owns the stop; only the scheduler + owns task creation, dependency rewrites, implementation-carrier transfer, + and retiring the original. A performer resolving an already published + split-required task reports it immediately without rerunning context, implementation, review, or builds. - Source lineage has a strict timing boundary. Before Phase 1, a missing approved prerequisite is a clean pre-phase routing stop: do not publish diff --git a/.agents/skills/process-inbox/SKILL.md b/.agents/skills/process-inbox/SKILL.md index 84c7862de7..2eeba196b3 100644 --- a/.agents/skills/process-inbox/SKILL.md +++ b/.agents/skills/process-inbox/SKILL.md @@ -63,6 +63,10 @@ paths. Follow `superseded_by` chains to their live task when deduplicating, resolving prior receipt references, or checking whether a same-digest result still exists. New dependencies and project links must name the final live task, never an alias. A dated slug occupied by an alias still counts as a collision. +Some retired oversized tasks instead have `split.yaml`, whose `split_into` +list resolves to several live successors. Treat the retained directory as +history, deduplicate against all live successors, and never put the split id in +a new dependency or project link. A split path is also permanently occupied. Use one disposable leaf planner when the harness supports delegation; instruct it not to delegate. Otherwise perform the same work locally. The planner may @@ -136,7 +140,10 @@ files, APIs, phases, ownership boundaries, and evidence design; it may veto the single-task shape when that richer proof exposes independently shippable and testable boundaries. That veto does not mean task sizing is based on elapsed time or diff length, and it does not authorize the performer to mutate the -queue itself. +queue itself. The performer publishes `split-required`; the checkout scheduler +then launches a dedicated deep split transaction that creates replacements, +rewrites dependencies and project links, and retires the source with a durable +multi-target split record. Project slugs are unique across `projects/` and `projects/archive/`. When a request belongs to an archived project, restore it before routing to it: @@ -224,11 +231,13 @@ claimed_at: null claim_order: null lease_until: null phase: null +carried_from: null inbox_receipt: receipts/YYYY/MM/DD/.md ``` Do not write a `model` field. It records which model finished the task, so only -`finish` writes it, at the canonical `Approve` or `Block` boundary; a task +`finish` writes it, at the canonical `Approve`, `Block`, or `Split-required` +boundary; a task carrying one before it is claimed is malformed. Use a project slug instead of `null` when routed to a project. Use a YAML list @@ -263,9 +272,9 @@ Create one tracked Markdown receipt under `receipts/YYYY/MM/DD/`. Include: - deduplication decisions. Before writing, search receipts for the same digest. If it was already fully -processed and every referenced task either has live state or has a durable alias -chain reaching live state, create nothing and reuse that receipt for -finalization. +processed and every referenced task either has live state, has a durable alias +chain reaching live state, or has a durable split record whose successors all +resolve to live state, create nothing and reuse that receipt for finalization. ## Validate and publish diff --git a/.agents/skills/process-inbox/scripts/workspace.py b/.agents/skills/process-inbox/scripts/workspace.py index c51d0782b2..0024b4f948 100755 --- a/.agents/skills/process-inbox/scripts/workspace.py +++ b/.agents/skills/process-inbox/scripts/workspace.py @@ -19,7 +19,13 @@ TASK_ID_PATTERN = re.compile( r"[0-9]{4}/[0-9]{2}/[0-9]{2}/[a-z0-9][a-z0-9-]*" ) SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") -VALID_STATUSES = {"todo", "in-progress", "approved", "blocked"} +VALID_STATUSES = { + "todo", + "in-progress", + "approved", + "blocked", + "split-required", +} DEFAULT_TASK_TYPE = "implement" LEGACY_TASK_TYPES = {"verify", "minimal"} VALID_TASK_TYPES = {DEFAULT_TASK_TYPE, *LEGACY_TASK_TYPES} @@ -44,6 +50,7 @@ STATE_FIELD_ORDER = [ "claim_order", "lease_until", "phase", + "carried_from", "model", "inbox_receipt", ] @@ -393,6 +400,12 @@ def load_state(root, path): model = parse_scalar(values.get("model", "null")) if model is not None and not MODEL_PATTERN.fullmatch(str(model)): raise WorkspaceError(f"Invalid model name {model!r} in {path}") + carried_from = parse_scalar(values.get("carried_from", "null")) + if ( + carried_from is not None + and not TASK_ID_PATTERN.fullmatch(str(carried_from)) + ): + raise WorkspaceError(f"Invalid carried_from in {path}: {carried_from!r}") task_id = task_id_for_state(root, path) task_file = path.with_name("task.md") title = task_id.rsplit("/", 1)[-1] @@ -414,6 +427,7 @@ def load_state(root, path): "claim_order": order, "lease_until": parse_scalar(values.get("lease_until", "null")), "phase": parse_scalar(values.get("phase", "null")), + "carried_from": carried_from, "model": model, "inbox_receipt": parse_scalar(values.get("inbox_receipt", "null")), "state_path": str(path), @@ -623,20 +637,24 @@ def publish_inbox(config): ) -def commit_paths(config, paths, subject): +def commit_paths(config, paths, subject, validate=None): slot = Path(config["slot_worktree"]) for path in paths: run_git(slot, "add", "--", path) if run_git(slot, "diff", "--cached", "--quiet", check=False).returncode == 0: raise WorkspaceError("No AI task state changed") run_git(slot, "commit", "-m", subject) - return publish_slot(config) + return publish_slot(config, validate=validate) def task_summary(task, states): return { **task, - "ready": task_ready(task, states), + "ready": ( + task_ready(task, states) + if task["status"] != "split" + else False + ), } @@ -651,6 +669,13 @@ def superseded_paths(root): return sorted(tasks.glob("*/*/*/*/superseded.yaml")) +def split_paths(root): + tasks = root / "tasks" + if not tasks.is_dir(): + return [] + return sorted(tasks.glob("*/*/*/*/split.yaml")) + + def retained_task_digest(directory): # Read the committed blobs rather than the working tree. A checkout may # materialize tracked text with different line endings than the commit @@ -678,7 +703,7 @@ def retained_task_digest(directory): if kind != b"blob": continue relative = PurePosixPath(path).relative_to(prefix).as_posix() - if relative in ("state.yaml", "superseded.yaml"): + if relative in ("state.yaml", "superseded.yaml", "split.yaml"): continue entries.append((relative, oid.decode("ascii"))) if not entries: @@ -746,6 +771,166 @@ def load_superseded(root): return result +def load_splits(root): + result = {} + for path in split_paths(root): + values = {} + for line in path.read_text(encoding="utf-8-sig").splitlines(): + if not line or line[0].isspace() or ":" not in line: + continue + key, value = line.split(":", 1) + values[key] = value.strip() + task_id = "/".join(path.relative_to(root).parts[1:5]) + if not TASK_ID_PATTERN.fullmatch(task_id): + raise WorkspaceError(f"Invalid split task path: {path}") + for field in ( + "split_into", + "implementation_carrier", + "receipt", + "type", + "created", + "project", + "depends_on", + "model", + "content_sha256", + ): + if field not in values: + raise WorkspaceError(f"Missing {field} in {path}") + targets = parse_dependencies(values["split_into"]) + if ( + len(targets) < 2 + or len(targets) != len(set(targets)) + or any(not TASK_ID_PATTERN.fullmatch(value) for value in targets) + or task_id in targets + ): + raise WorkspaceError(f"Invalid split_into in {path}: {targets!r}") + carrier = parse_scalar(values["implementation_carrier"]) + if carrier is not None and carrier not in targets: + raise WorkspaceError( + f"Invalid implementation_carrier in {path}: {carrier!r}" + ) + kind = parse_scalar(values["type"]) + if kind not in VALID_TASK_TYPES: + raise WorkspaceError(f"Invalid type in {path}: {kind!r}") + project = parse_scalar(values["project"]) + if project is not None and not TAG_PATTERN.fullmatch(str(project)): + raise WorkspaceError(f"Invalid project in {path}: {project!r}") + receipt = parse_scalar(values["receipt"]) + if not isinstance(receipt, str) or not receipt.startswith("receipts/"): + raise WorkspaceError(f"Invalid receipt in {path}: {receipt!r}") + content_digest = parse_scalar(values["content_sha256"]) + if ( + not isinstance(content_digest, str) + or not SHA256_PATTERN.fullmatch(content_digest) + ): + raise WorkspaceError( + f"Invalid content_sha256 in {path}: {content_digest!r}" + ) + dependencies = parse_dependencies(values["depends_on"]) + if any(not TASK_ID_PATTERN.fullmatch(value) for value in dependencies): + raise WorkspaceError(f"Invalid depends_on in {path}: {dependencies!r}") + model = parse_scalar(values["model"]) + if model is None or not MODEL_PATTERN.fullmatch(str(model)): + raise WorkspaceError(f"Invalid model in {path}: {model!r}") + result[task_id] = { + "id": task_id, + "split_into": targets, + "implementation_carrier": carrier, + "receipt": receipt, + "type": kind, + "created": str(parse_scalar(values["created"])), + "project": project, + "depends_on": dependencies, + "model": model, + "content_sha256": content_digest, + "path": str(path), + } + return result + + +def resolve_split_targets(states, superseded, splits, task_id, visited=None): + visited = [] if visited is None else visited + if task_id in visited: + raise WorkspaceError( + "Split task cycle: " + " -> ".join(visited + [task_id]) + ) + if task_id in states: + return [task_id] + if task_id in superseded: + return resolve_split_targets( + states, + superseded, + splits, + superseded[task_id]["superseded_by"], + visited + [task_id], + ) + split = splits.get(task_id) + if split is None: + raise WorkspaceError(f"Split task {task_id} targets missing task state") + result = [] + for target in split["split_into"]: + for resolved in resolve_split_targets( + states, + superseded, + splits, + target, + visited + [task_id], + ): + if resolved not in result: + result.append(resolved) + return result + + +def split_task_summary(root, states, superseded, splits, task_id): + split = splits[task_id] + task_file = root / "tasks" / task_id / "task.md" + title = task_id.rsplit("/", 1)[-1] + if task_file.is_file(): + for line in task_file.read_text(encoding="utf-8-sig").splitlines(): + if line.startswith("# "): + title = line[2:].strip() + break + return { + "id": task_id, + "title": title, + "status": "split", + "type": split["type"], + "created": split["created"], + "project": split["project"], + "depends_on": split["depends_on"], + "claimed_by": None, + "split_into": resolve_split_targets( + states, superseded, splits, task_id, + ), + "implementation_carrier": split["implementation_carrier"], + "receipt": split["receipt"], + "model": split["model"], + } + + +def resolve_task_reference(root, states, superseded, splits, task_id): + visited = [] + current = task_id + while current not in states and current not in splits: + if current in visited: + raise WorkspaceError( + "Superseded task cycle: " + " -> ".join(visited + [current]) + ) + visited.append(current) + alias = superseded.get(current) + if alias is None: + raise WorkspaceError(f"Task does not exist: {task_id}") + current = alias["superseded_by"] + result = ( + dict(states[current]) + if current in states + else split_task_summary(root, states, superseded, splits, current) + ) + if current != task_id: + result["superseded_from"] = task_id + return result + + def resolve_task_id(states, superseded, task_id): visited = [] current = task_id @@ -767,8 +952,9 @@ def resolve_task_id(states, superseded, task_id): def resolve_task(root, states, value): superseded = load_superseded(root) + splits = load_splits(root) if TASK_ID_PATTERN.fullmatch(value): - return resolve_task_id(states, superseded, value) + return resolve_task_reference(root, states, superseded, splits, value) name = normalized_task_name(value) if not name: raise WorkspaceError("Task name is empty") @@ -787,13 +973,24 @@ def resolve_task(root, states, value): if task_id.rsplit("/", 1)[-1] == name ] exact = [ - resolve_task_id(states, superseded, task_id) + resolve_task_reference(root, states, superseded, splits, task_id) for task_id in alias_ids ] exact = list({task["id"]: task for task in exact}.values()) + if not exact: + split_ids = [ + task_id for task_id in splits + if task_id.rsplit("/", 1)[-1] == name + ] + exact = [ + split_task_summary(root, states, superseded, splits, task_id) + for task_id in split_ids + ] unfinished = [ task for task in exact - if task["status"] in ("todo", "in-progress", "blocked") + if task["status"] in ( + "todo", "in-progress", "blocked", "split-required" + ) ] candidates = unfinished or exact if not candidates: @@ -859,6 +1056,14 @@ def command_queue(args): ), key=queue_sort_key, ) + own_split_required = sorted( + ( + task for task in values + if task["claimed_by"] == tag + and task["status"] == "split-required" + ), + key=queue_sort_key, + ) unclaimed_todo = sorted( ( task for task in values @@ -869,11 +1074,19 @@ def command_queue(args): violations = [] if len(own_in_progress) > 1: violations.append(f"{tag} has more than one in-progress task") + if len(own_split_required) > 1: + violations.append(f"{tag} has more than one split-required task") + if own_in_progress and own_split_required: + violations.append( + f"{tag} has both in-progress and split-required work" + ) for task in values: if task["status"] == "in-progress" and task["claimed_by"] is None: violations.append(f"{task['id']} is in-progress but unclaimed") if task["status"] == "blocked" and task["claimed_by"] is None: violations.append(f"{task['id']} is blocked but unclaimed") + if task["status"] == "split-required" and task["claimed_by"] is None: + violations.append(f"{task['id']} is split-required but unclaimed") inbox_file = main / "inbox" / "inbox.md" result = { **config, @@ -884,12 +1097,15 @@ def command_queue(args): "own_in_progress": own_in_progress, "own_todo": own_todo, "own_blocked": own_blocked, + "own_split_required": own_split_required, "unclaimed_todo": unclaimed_todo, "pending_consolidations": pending_consolidations(slot), "other_claimed_unfinished": sum( 1 for task in values if task["claimed_by"] not in (None, tag) - and task["status"] in ("todo", "in-progress", "blocked") + and task["status"] in ( + "todo", "in-progress", "blocked", "split-required" + ) ), "ai_main_dirty": main_dirty, "slot_dirty": slot_dirty, @@ -915,7 +1131,7 @@ def command_resolve(args): active = sorted( value["id"] for value in states.values() if value["claimed_by"] == config["checkout_tag"] - and value["status"] == "in-progress" + and value["status"] in ("in-progress", "split-required") ) print(json.dumps({ **config, @@ -944,6 +1160,18 @@ def command_start(args): raise WorkspaceError( f"Task is not available todo work for this checkout: {args.task}" ) + carried_from = task["carried_from"] + if carried_from is not None: + split = load_splits(slot).get(carried_from) + if ( + split is None + or split["implementation_carrier"] != args.task + or task["claimed_by"] != config["checkout_tag"] + ): + raise WorkspaceError( + f"Task has an invalid carried implementation source: {args.task}" + ) + validate_carried_worktree(config, slot, carried_from) if not task_ready(task, states): raise WorkspaceError(f"Task has unfinished dependencies: {args.task}") lineage = source_lineage_report( @@ -967,10 +1195,12 @@ def command_start(args): active = [ value["id"] for value in states.values() if value["claimed_by"] == config["checkout_tag"] - and value["status"] == "in-progress" + and value["status"] in ("in-progress", "split-required") ] if active: raise WorkspaceError("Another task is already in progress: " + ", ".join(active)) + if carried_from is not None: + transfer_source_refs(config, carried_from, args.task) path = state_path(slot, args.task) update_state(path, { "status": "in-progress", @@ -1010,6 +1240,8 @@ def command_start(args): f"Start lost to newer master for {args.task}" ) from error raise + if carried_from is not None: + delete_source_refs(config, carried_from) print(json.dumps({ "task": args.task, "status": "in-progress", @@ -1069,7 +1301,7 @@ def command_retry(args): active = [ value["id"] for value in states.values() if value["claimed_by"] == config["checkout_tag"] - and value["status"] == "in-progress" + and value["status"] in ("in-progress", "split-required") ] if active: raise WorkspaceError("Another task is already in progress: " + ", ".join(active)) @@ -1228,10 +1460,13 @@ def source_lineage_report(config, slot, task_id, extra_requirements=()): required.append(resolved) source = Path(config["source_root"]) - tips = { - dependency: task_tip_commits(source, dependency) - for dependency in required - } + tips = {} + for dependency in required: + commits = task_tip_commits(source, dependency) + carried_from = states[dependency]["carried_from"] + if not commits and carried_from is not None: + commits = task_tip_commits(source, carried_from) + tips[dependency] = commits missing_history = [ dependency for dependency, commits in tips.items() if not commits @@ -1290,6 +1525,13 @@ def task_type(slot, task_id): def validate_source_state(config, task_id, expected_commit): source = Path(config["source_root"]) + slot_value = config.get("slot_worktree") + carried_from = None + if slot_value: + slot = Path(slot_value) + path = slot / task_relative_dir(task_id) / "state.yaml" + if path.is_file(): + carried_from = load_state(slot, path)["carried_from"] base = resolved_ref(source, source_task_ref(task_id, "base")) green = resolved_ref(source, source_task_ref(task_id, "green")) run = resolved_ref(source, source_task_ref(task_id, "run")) @@ -1314,7 +1556,11 @@ def validate_source_state(config, task_id, expected_commit): raise WorkspaceError( "The retained task implementation is not in Telegram HEAD history" ) - validate_task_commit(source, green, task_id) + if not task_commit_matches(source, green, task_id): + if carried_from is None or not task_commit_matches( + source, green, carried_from, + ): + validate_task_commit(source, green, task_id) def ensure_no_persisted_commit_hashes(root): @@ -1342,9 +1588,33 @@ def delete_source_refs(config, task_id, retain_implementation=False): run_git(source, "update-ref", "-d", value) -def command_source_begin(args): - config, _ = task_action_config(args) +def transfer_source_refs(config, source_task, target_task): source = Path(config["source_root"]) + base = resolved_ref(source, source_task_ref(source_task, "base")) + run = resolved_ref(source, source_task_ref(source_task, "run")) + head = resolved_ref(source, "HEAD") + if base is None or run is None or run != head: + raise WorkspaceError( + f"Carried source refs are incomplete or stale for {source_task}" + ) + for name in ("base", "green", "run"): + value = resolved_ref(source, source_task_ref(source_task, name)) + target = source_task_ref(target_task, name) + if value is None: + if resolved_ref(source, target) is not None: + run_git(source, "update-ref", "-d", target) + else: + run_git(source, "update-ref", target, value) + + +def command_source_begin(args): + config, slot = task_action_config(args) + source = Path(config["source_root"]) + carried_from = ( + load_state(slot, state_path(slot, args.task))["carried_from"] + if slot is not None + else None + ) base = source_task_ref(args.task, "base") green = source_task_ref(args.task, "green") run = source_task_ref(args.task, "run") @@ -1358,7 +1628,23 @@ def command_source_begin(args): and is_ancestor(source, green_value, head) and task_commit_matches(source, green_value, args.task) ) - if retained or (base_value is not None and green_value is None and head == base_value): + carried = ( + carried_from is not None + and base_value is not None + and resolved_ref(source, run) == head + and ( + green_value is None + or task_commit_matches(source, green_value, args.task) + or task_commit_matches(source, green_value, carried_from) + ) + ) + if carried: + state = "adopted" + elif retained or ( + base_value is not None + and green_value is None + and head == base_value + ): state = "resumed" else: series = task_series_refs(source, args.task) @@ -2518,7 +2804,10 @@ def command_source_commit(args): if not owned: raise WorkspaceError(f"Empty owned-paths inventory: {owned_file}") source_note = f"tasks/{args.task}.md" + carried_from = load_state(slot, state_path(slot, args.task))["carried_from"] allowed = owned + [source_note] + if carried_from is not None: + allowed.append(f"tasks/{carried_from}.md") dirty = changed_paths(source) if not dirty: raise WorkspaceError("The source checkout has no changes to commit") @@ -2771,6 +3060,195 @@ def validate_blocked_result(lines, result_path): ) +def owned_source_paths(slot, task_id): + path = slot / task_relative_dir(task_id) / "work" / "owned-paths.txt" + if not path.is_file(): + return [] + return [ + line.strip() + for line in path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() + ] + + +def source_worktree_snapshot(config, slot, task_id): + source = Path(config["source_root"]) + owned = owned_source_paths(slot, task_id) + dirty = changed_paths(source) + allowed = owned + [f"tasks/{task_id}.md"] + gitlinks = set(gitlink_paths(source, dirty)) + nested_owned = {} + for path in gitlinks: + prefix = path + "/" + nested_allowed = [ + value[len(prefix):] + for value in allowed + if value.startswith(prefix) + ] + if not nested_allowed: + continue + nested_dirty = changed_paths(source / path) + if all(path_is_covered(value, nested_allowed) for value in nested_dirty): + nested_owned[path] = (nested_allowed, nested_dirty) + owned_dirty = [ + path for path in dirty + if path_is_covered(path, allowed) or path in nested_owned + ] + outside = [path for path in dirty if path not in owned_dirty] + digest = hashlib.sha256() + if owned_dirty: + digest.update(run_git_binary( + source, + "diff", + "--binary", + "--submodule=diff", + "HEAD", + "--", + *owned_dirty, + )) + untracked = set(literal_paths( + source, + "ls-files", + "--others", + "--exclude-standard", + "--", + *owned_dirty, + )) + for relative in sorted(untracked): + path = source / relative + name = relative.encode("utf-8") + digest.update(len(name).to_bytes(8, "big")) + digest.update(name) + if path.is_file(): + data = path.read_bytes() + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + for submodule, (nested_allowed, nested_dirty) in sorted( + nested_owned.items() + ): + nested = source / submodule + nested_untracked = set(literal_paths( + nested, + "ls-files", + "--others", + "--exclude-standard", + "--", + *nested_dirty, + )) if nested_dirty else set() + for relative in sorted(nested_untracked): + if not path_is_covered(relative, nested_allowed): + continue + path = nested / relative + name = f"{submodule}/{relative}".encode("utf-8") + digest.update(len(name).to_bytes(8, "big")) + digest.update(name) + if path.is_file(): + data = path.read_bytes() + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return { + "owned_dirty_paths": owned_dirty, + "outside_owned_paths": outside, + "worktree_digest": digest.hexdigest(), + } + + +def validate_carried_worktree(config, slot, task_id): + carried_path = slot / task_relative_dir(task_id) / "work" / "carried-work.json" + if not carried_path.is_file(): + raise WorkspaceError(f"Split source lacks carried-work.json: {carried_path}") + carried = json.loads(carried_path.read_text(encoding="utf-8-sig")) + current = source_worktree_snapshot(config, slot, task_id) + for field in ( + "owned_dirty_paths", + "outside_owned_paths", + "worktree_digest", + ): + if carried.get(field) != current[field]: + raise WorkspaceError( + f"Source worktree changed after split-required publication: {field}" + ) + return carried + + +def reconcile_split_source_refs(config, task_id): + source = Path(config["source_root"]) + base_ref = source_task_ref(task_id, "base") + green_ref = source_task_ref(task_id, "green") + run_ref = source_task_ref(task_id, "run") + base = resolved_ref(source, base_ref) + run = resolved_ref(source, run_ref) + head = resolved_ref(source, "HEAD") + if base is None or run is None or run != head: + raise WorkspaceError( + "Split-required publication needs current task base/run refs at HEAD" + ) + green = resolved_ref(source, green_ref) + if green is None: + series = task_series_refs(source, task_id) + if series is not None: + _, green = series + run_git(source, "update-ref", green_ref, green) + if green is not None: + if not is_ancestor(source, green, head): + raise WorkspaceError( + "The retained split implementation is not in current source history" + ) + validate_task_commit(source, green, task_id) + return green is not None + + +def validate_split_required_result(lines, result_path, retained): + if "STATUS: SPLIT_REQUIRED" not in lines: + raise WorkspaceError( + f"Task result does not contain STATUS: SPLIT_REQUIRED: {result_path}" + ) + if "Verdict: SPLIT_REQUIRED" not in lines: + raise WorkspaceError( + f"Task result does not contain Verdict: SPLIT_REQUIRED: {result_path}" + ) + if "Checkout: source-state-retained" not in lines: + raise WorkspaceError( + f"Split result does not preserve source state: {result_path}" + ) + outcome = required_result_value(lines, result_path, "Outcome") + if outcome != "split-required": + raise WorkspaceError( + f"Split result Outcome must be split-required: {result_path}" + ) + implementation = required_result_value(lines, result_path, "Implementation") + expected = "retained" if retained else "none" + if implementation != expected: + raise WorkspaceError( + f"Split result Implementation must be {expected}: {result_path}" + ) + touched = required_result_value(lines, result_path, "Touched") + if retained == (touched == "none"): + raise WorkspaceError( + f"Split result Touched does not match retained implementation: {result_path}" + ) + if "Split-Proposal: work/split-proposal.md" not in lines: + raise WorkspaceError( + f"Split result must name work/split-proposal.md: {result_path}" + ) + proposal = result_path.parent / "split-proposal.md" + if not proposal.is_file() or not proposal.read_text( + encoding="utf-8-sig", + ).strip(): + raise WorkspaceError(f"Split proposal is missing or empty: {proposal}") + + +def write_carried_work(path, snapshot, retained): + payload = { + "implementation": "retained" if retained else "none", + **snapshot, + } + state = path.parents[1] / "state.yaml" + newline = "\r\n" if b"\r\n" in state.read_bytes() else "\n" + text = json.dumps(payload, indent=2, sort_keys=True) + "\n" + path.write_bytes(text.replace("\n", newline).encode("utf-8")) + + def command_finish(args): model = args.model.strip() if not MODEL_PATTERN.fullmatch(model): @@ -2780,28 +3258,44 @@ def command_finish(args): "gpt-5.6-sol, glm-5.3." ) config, slot = task_action_config(args, allow_project=True) - ensure_clean(Path(config["source_root"]), "Telegram source checkout") kind = task_type(slot, args.task) if kind != DEFAULT_TASK_TYPE: raise WorkspaceError( f"Historical task type {kind!r} cannot enter the current workflow" ) approved = args.status == "approved" + split_required = args.status == "split-required" result_path = slot / task_relative_dir(args.task) / "work" / "result.md" if not result_path.is_file(): raise WorkspaceError(f"Task result is missing: {result_path}") result = result_path.read_text(encoding="utf-8-sig") lines = result.splitlines() - expected = "STATUS: DONE" if approved else "STATUS: BLOCKED" - if expected not in lines: - raise WorkspaceError(f"Task result does not contain {expected}: {result_path}") + if not split_required: + ensure_clean(Path(config["source_root"]), "Telegram source checkout") + expected = "STATUS: DONE" if approved else "STATUS: BLOCKED" + if expected not in lines: + raise WorkspaceError( + f"Task result does not contain {expected}: {result_path}" + ) if approved and "Verdict: APPROVED" not in lines: raise WorkspaceError(f"Task result does not contain an approved verdict: {result_path}") - if "Checkout: clean-buildable" not in lines: + if not split_required and "Checkout: clean-buildable" not in lines: raise WorkspaceError(f"Task result does not confirm a clean checkout: {result_path}") - if not approved: + if not approved and not split_required: validate_blocked_result(lines, result_path) - outcome = validate_outcome_result(lines, result_path, approved) + if split_required: + snapshot = source_worktree_snapshot(config, slot, args.task) + retained_commit = reconcile_split_source_refs(config, args.task) + retained = retained_commit or bool(snapshot["owned_dirty_paths"]) + validate_split_required_result(lines, result_path, retained) + write_carried_work( + result_path.parent / "carried-work.json", + snapshot, + retained, + ) + outcome = "split-required" + else: + outcome = validate_outcome_result(lines, result_path, approved) if approved and not (result_path.parent / "test.md").is_file(): raise WorkspaceError( "An approved task must retain work/test.md as adaptive evidence: " @@ -2811,18 +3305,25 @@ def command_finish(args): source_note = Path(config["source_root"]) / "tasks" / f"{args.task}.md" if source_note.is_file(): ensure_no_persisted_commit_hashes(source_note) - expected_commit = ( - outcome == "changed" if approved else None - ) - validate_source_state(config, args.task, expected_commit) + if not split_required: + expected_commit = outcome == "changed" if approved else None + validate_source_state(config, args.task, expected_commit) path = state_path(slot, args.task) update_state(path, { "status": args.status, - "phase": "complete" if args.status == "approved" else "blocked", + "phase": ( + "complete" if approved + else "split-required" if split_required + else "blocked" + ), "lease_until": None, "model": model, }) - verb = "Approve" if args.status == "approved" else "Block" + verb = ( + "Approve" if approved + else "Split-required" if split_required + else "Block" + ) paths = [task_relative_dir(args.task)] project = load_state(slot, path)["project"] if project is not None: @@ -2835,11 +3336,12 @@ def command_finish(args): paths, f"{verb} {args.task}", ) - delete_source_refs( - config, - args.task, - retain_implementation=(args.status == "blocked"), - ) + if not split_required: + delete_source_refs( + config, + args.task, + retain_implementation=(args.status == "blocked"), + ) print(json.dumps({ "task": args.task, "status": args.status, @@ -2850,8 +3352,16 @@ def command_finish(args): def command_publish(args): config = worktree_config(args, create=True) slot = Path(config["slot_worktree"]) - validate = consolidation_validation_for_head(slot) + consolidation_validate = consolidation_validation_for_head(slot) + split_validate = split_validation_for_head(slot) + validate = consolidation_validate or split_validate published = publish_slot(config, validate=validate) + if published and split_validate is not None: + source_task = run_git( + slot, "show", "-s", "--format=%s", "HEAD", + ).stdout.strip()[len("Split "):] + if load_splits(slot)[source_task]["implementation_carrier"] is None: + delete_source_refs(config, source_task) print(json.dumps({"published": bool(published)}, indent=2, sort_keys=True)) @@ -2932,6 +3442,7 @@ def validate_dependency_graph(states): def validate_superseded_graph(root, states, superseded): + splits = load_splits(root) for task_id, alias in superseded.items(): if task_id in states: raise WorkspaceError( @@ -2947,7 +3458,7 @@ def validate_superseded_graph(root, states, superseded): ) current = task_id visited = [] - while current not in states: + while current not in states and current not in splits: if current in visited: raise WorkspaceError( "Superseded task cycle: " + " -> ".join(visited + [current]) @@ -2961,9 +3472,290 @@ def validate_superseded_graph(root, states, superseded): current = current_alias["superseded_by"] +def validate_split_graph(root, states, superseded, splits): + for task_id, split in splits.items(): + if task_id in states or task_id in superseded: + raise WorkspaceError( + f"Split task still has live or superseded state: {task_id}" + ) + directory = root / "tasks" / task_id + if not (directory / "task.md").is_file(): + raise WorkspaceError(f"Split task lost task.md: {task_id}") + actual_digest = retained_task_digest(directory) + if actual_digest != split["content_sha256"]: + raise WorkspaceError(f"Split task retained content changed: {task_id}") + receipt = root / split["receipt"] + if not receipt.is_file(): + raise WorkspaceError(f"Split task receipt is missing: {receipt}") + resolve_split_targets(states, superseded, splits, task_id) + + +def validate_split_tree(root, source_task, replacements, receipt, carrier): + states = load_states(root) + superseded = load_superseded(root) + splits = load_splits(root) + split = splits.get(source_task) + if split is None: + raise WorkspaceError(f"Missing split record for {source_task}") + if split["split_into"] != replacements: + raise WorkspaceError( + f"Split replacements changed for {source_task}: {split['split_into']!r}" + ) + if split["receipt"] != receipt or split["implementation_carrier"] != carrier: + raise WorkspaceError(f"Split routing metadata changed for {source_task}") + validate_dependency_graph(states) + validate_superseded_graph(root, states, superseded) + validate_split_graph(root, states, superseded, splits) + receipt_path = root / receipt + receipt_text = receipt_path.read_text(encoding="utf-8-sig") + for task_id in [source_task, *replacements]: + if task_id not in receipt_text: + raise WorkspaceError(f"Split receipt omits task {task_id}: {receipt}") + for index, task_id in enumerate(replacements): + task = states.get(task_id) + if task is None: + raise WorkspaceError(f"Split replacement has no live state: {task_id}") + if task["type"] != DEFAULT_TASK_TYPE or task["status"] != "todo": + raise WorkspaceError( + f"Split replacement is not todo implementation work: {task_id}" + ) + if task["inbox_receipt"] != receipt or task["model"] is not None: + raise WorkspaceError( + f"Split replacement has invalid routing state: {task_id}" + ) + if task_id == carrier: + if index != 0: + raise WorkspaceError( + "The implementation carrier must be the first replacement" + ) + if ( + task["carried_from"] != source_task + or task["claimed_by"] is None + or task["claimed_at"] is None + or task["claim_order"] is None + or task["phase"] is not None + ): + raise WorkspaceError( + f"Implementation carrier does not own the split handoff: {task_id}" + ) + elif any(task[field] is not None for field in ( + "claimed_by", + "claimed_at", + "claim_order", + "lease_until", + "phase", + "carried_from", + )): + raise WorkspaceError( + f"Non-carrier split replacement is not pristine: {task_id}" + ) + for project_path in sorted((root / "projects").glob("*/tasks.md")): + text = project_path.read_text(encoding="utf-8-sig") + if f"tasks/{source_task}/task.md" in text: + raise WorkspaceError( + f"Project index still links split task {source_task}: {project_path}" + ) + for task_id in replacements: + project = states[task_id]["project"] + if project is None: + continue + project_path = root / "projects" / project / "tasks.md" + if ( + not project_path.is_file() + or f"tasks/{task_id}/task.md" not in project_path.read_text( + encoding="utf-8-sig", + ) + ): + raise WorkspaceError( + f"Project index omits split replacement {task_id}: {project_path}" + ) + + +def split_validation_for_head(slot): + subject = run_git(slot, "show", "-s", "--format=%s", "HEAD").stdout.strip() + prefix = "Split " + if not subject.startswith(prefix): + return None + source_task = subject[len(prefix):] + if not TASK_ID_PATTERN.fullmatch(source_task): + raise WorkspaceError(f"Invalid split commit subject: {subject!r}") + split = load_splits(slot).get(source_task) + if split is None: + raise WorkspaceError(f"Split commit lost its split record: {source_task}") + return lambda root: validate_split_tree( + root, + source_task, + split["split_into"], + split["receipt"], + split["implementation_carrier"], + ) + + +def write_split_record(path, source_state, replacements, receipt, carrier, digest): + newline = "\r\n" if b"\r\n" in path.with_name("state.yaml").read_bytes() else "\n" + values = [ + "split_into: [" + ", ".join(replacements) + "]", + f"implementation_carrier: {format_scalar(carrier)}", + f"receipt: {receipt}", + f"type: {source_state['type']}", + f"created: {source_state['created']}", + f"project: {format_scalar(source_state['project'])}", + "depends_on: [" + ", ".join(source_state["depends_on"]) + "]", + f"model: {source_state['model']}", + f"content_sha256: {digest}", + ] + path.write_bytes((newline.join(values) + newline).encode("utf-8")) + + +def command_split_publish(args): + config = worktree_config(args, create=True) + slot = Path(config["slot_worktree"]) + ensure_clean(Path(config["ai_main"]), "ai-tdesktop master") + if not TASK_ID_PATTERN.fullmatch(args.source_task): + raise WorkspaceError(f"Invalid split source task: {args.source_task!r}") + replacements = list(dict.fromkeys(args.replacements)) + if ( + len(replacements) < 2 + or len(replacements) != len(args.replacements) + or any(not TASK_ID_PATTERN.fullmatch(value) for value in replacements) + or args.source_task in replacements + ): + raise WorkspaceError("A split needs at least two unique replacement task ids") + carrier = args.implementation_carrier + if carrier is not None and (carrier not in replacements or carrier != replacements[0]): + raise WorkspaceError( + "The implementation carrier must be the first replacement task" + ) + receipt = normalized_publish_path(args.receipt) + if not receipt.startswith("receipts/"): + raise WorkspaceError("The split receipt must be below receipts/") + paths = sorted({normalized_publish_path(value) for value in args.paths}) + required_paths = [ + f"tasks/{args.source_task}", + *(f"tasks/{task_id}" for task_id in replacements), + receipt, + ] + for required in required_paths: + if not path_is_covered(required, paths): + raise WorkspaceError( + f"Split publication path does not cover {required}" + ) + changes = changed_paths(slot) + unexpected = [path for path in changes if not path_is_covered(path, paths)] + if unexpected: + raise WorkspaceError( + "Split changes are outside the explicit publication paths: " + + ", ".join(unexpected) + ) + source_prefix = f"tasks/{args.source_task}/" + source_changes = [path for path in changes if path.startswith(source_prefix)] + if source_changes: + raise WorkspaceError( + "The split worker must not rewrite retained source-task content: " + + ", ".join(source_changes) + ) + states = load_states(slot) + source_state = states.get(args.source_task) + if ( + source_state is None + or source_state["status"] != "split-required" + or source_state["claimed_by"] != config["checkout_tag"] + or source_state["model"] is None + ): + raise WorkspaceError( + f"Task is not split-required work owned by this checkout: {args.source_task}" + ) + carried = validate_carried_worktree(config, slot, args.source_task) + retained = carried.get("implementation") == "retained" + if retained != (carrier is not None): + raise WorkspaceError( + "Retained implementation requires exactly one implementation carrier" + ) + for task_id in replacements: + task = states.get(task_id) + if task is None: + raise WorkspaceError(f"Split replacement is missing: {task_id}") + if ( + task["status"] != "todo" + or task["type"] != DEFAULT_TASK_TYPE + or task["claimed_by"] is not None + or task["model"] is not None + or task["carried_from"] is not None + or task["inbox_receipt"] != receipt + ): + raise WorkspaceError( + f"Split replacement is not pristine routed todo work: {task_id}" + ) + if carrier is not None: + old_owned = ( + slot / task_relative_dir(args.source_task) / "work" / "owned-paths.txt" + ) + if not old_owned.is_file(): + raise WorkspaceError( + f"Retained implementation lacks owned-paths.txt: {old_owned}" + ) + carrier_work = slot / task_relative_dir(carrier) / "work" + carrier_work.mkdir(parents=True, exist_ok=True) + shutil.copyfile(old_owned, carrier_work / "owned-paths.txt") + update_state(state_path(slot, carrier), { + "claimed_by": config["checkout_tag"], + "claimed_at": source_state["claimed_at"], + "claim_order": source_state["claim_order"] or 1, + "lease_until": None, + "phase": None, + "carried_from": args.source_task, + }) + source_dir = slot / task_relative_dir(args.source_task) + digest = retained_task_digest(source_dir) + source_state_path = source_dir / "state.yaml" + split_path = source_dir / "split.yaml" + write_split_record( + split_path, + source_state, + replacements, + receipt, + carrier, + digest, + ) + source_state_path.unlink() + post_changes = changed_paths(slot) + required_source_changes = { + f"tasks/{args.source_task}/state.yaml", + f"tasks/{args.source_task}/split.yaml", + } + actual_source_changes = { + path for path in post_changes if path.startswith(source_prefix) + } + if actual_source_changes != required_source_changes: + raise WorkspaceError( + "Split retirement changed unexpected source-task content: " + + ", ".join(sorted(actual_source_changes ^ required_source_changes)) + ) + validate = lambda root: validate_split_tree( + root, args.source_task, replacements, receipt, carrier, + ) + validate(slot) + commit = commit_paths( + config, + paths, + f"Split {args.source_task}", + validate=validate, + ) + if carrier is None: + delete_source_refs(config, args.source_task) + print(json.dumps({ + "implementation_carrier": carrier, + "published": bool(commit), + "replacements": replacements, + "source_task": args.source_task, + "status": "split", + }, indent=2, sort_keys=True)) + + def validate_consolidation_tree(root, source_task, mappings, receipt): states = load_states(root) superseded = load_superseded(root) + splits = load_splits(root) if source_task not in states: raise WorkspaceError( f"Consolidation source task does not exist: {source_task}" @@ -2988,6 +3780,7 @@ def validate_consolidation_tree(root, source_task, mappings, receipt): ) validate_dependency_graph(states) validate_superseded_graph(root, states, superseded) + validate_split_graph(root, states, superseded, splits) if not mappings: if receipt is not None: raise WorkspaceError("A no-merge consolidation must not publish a receipt") @@ -3833,7 +4626,11 @@ def parse_args(): finish = subparsers.add_parser("finish") add_common_arguments(finish) finish.add_argument("--task", required=True) - finish.add_argument("--status", choices=("approved", "blocked"), required=True) + finish.add_argument( + "--status", + choices=("approved", "blocked", "split-required"), + required=True, + ) finish.add_argument("--model", required=True) finish.set_defaults(handler=command_finish) @@ -3871,6 +4668,25 @@ def parse_args(): ) consolidate_publish.set_defaults(handler=command_consolidate_publish) + split_publish = subparsers.add_parser("split-publish") + add_common_arguments(split_publish) + split_publish.add_argument("--source-task", required=True) + split_publish.add_argument("--receipt", required=True) + split_publish.add_argument( + "--replacement", + action="append", + dest="replacements", + required=True, + ) + split_publish.add_argument("--implementation-carrier") + split_publish.add_argument( + "--path", + action="append", + dest="paths", + required=True, + ) + split_publish.set_defaults(handler=command_split_publish) + task_content_digest = subparsers.add_parser("task-content-digest") add_common_arguments(task_content_digest) task_content_digest.add_argument("--task", required=True) diff --git a/.agents/skills/process-inbox/scripts/workspace_test.py b/.agents/skills/process-inbox/scripts/workspace_test.py index bf883d064f..613a9e4030 100644 --- a/.agents/skills/process-inbox/scripts/workspace_test.py +++ b/.agents/skills/process-inbox/scripts/workspace_test.py @@ -2898,6 +2898,226 @@ class MechanicsTest(unittest.TestCase): self.assertTrue(result["golden_account_present"]) self.assertFalse(result["live_marker_present"]) + def test_finish_publishes_split_required_with_carried_work(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, slot, work, config = source_repo_with_task(root) + config["slot_worktree"] = str(slot) + for name in ("base", "run"): + git( + source, + "update-ref", + workspace.source_task_ref(TASK_ID, name), + "HEAD", + ) + (work / "owned-paths.txt").write_text( + "tracked.txt\n", encoding="utf-8", + ) + (work / "split-proposal.md").write_text( + "# Split proposal\n\nTwo independent boundaries.\n", + encoding="utf-8", + ) + (work / "result.md").write_text( + """STATUS: SPLIT_REQUIRED +Outcome: split-required +Verdict: SPLIT_REQUIRED +Implementation: retained +Touched: tracked.txt +Split-Proposal: work/split-proposal.md +Checkout: source-state-retained +""", + encoding="utf-8", + ) + (source / "tracked.txt").write_text("carried\n", encoding="utf-8") + with ( + mock.patch.object( + workspace, + "task_action_config", + return_value=(config, slot), + ), + mock.patch.object(workspace, "commit_paths", return_value=True), + ): + result = run_command( + workspace.command_finish, + task=TASK_ID, + status="split-required", + model="gpt-5.6-sol", + ) + + state = workspace.load_state(slot, work.parent / "state.yaml") + self.assertEqual(result["status"], "split-required") + self.assertEqual(state["status"], "split-required") + self.assertEqual(state["phase"], "split-required") + carried = json.loads((work / "carried-work.json").read_text( + encoding="utf-8-sig", + )) + self.assertEqual(carried["implementation"], "retained") + self.assertEqual(carried["owned_dirty_paths"], ["tracked.txt"]) + self.assertEqual((source / "tracked.txt").read_text(), "carried\n") + + def test_carried_work_snapshot_seals_owned_submodule_changes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, slot, work, config = source_repo_with_task(root) + nested = source / "nested" + git_repo(nested) + (nested / "owned.txt").write_text("base\n", encoding="utf-8") + git(nested, "add", "owned.txt") + git(nested, "commit", "-m", "Create nested baseline") + nested_head = git(nested, "rev-parse", "HEAD") + git( + source, + "update-index", + "--add", + "--cacheinfo", + f"160000,{nested_head},nested", + ) + git(source, "commit", "-m", "Track nested repository") + (work / "owned-paths.txt").write_text( + "nested/owned.txt\n", encoding="utf-8", + ) + (nested / "owned.txt").write_text("carried\n", encoding="utf-8") + + snapshot = workspace.source_worktree_snapshot( + config, slot, TASK_ID, + ) + + self.assertEqual(snapshot["owned_dirty_paths"], ["nested"]) + self.assertEqual(snapshot["outside_owned_paths"], []) + self.assertNotEqual(snapshot["worktree_digest"], "0" * 64) + + def test_split_publish_routes_and_starts_implementation_carrier(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config = inbox_worktrees(root) + main = Path(config["ai_main"]) + slot = Path(config["slot_worktree"]) + source = root / "source" + git_repo(source) + (source / "Telegram" / "build").mkdir(parents=True) + (source / "tracked.txt").write_text("base\n", encoding="utf-8") + git(source, "add", "-A") + git(source, "commit", "-m", "Create baseline") + config["source_root"] = str(source) + source_task = "2026/07/18/active-task" + source_dir = main / "tasks" / source_task + state_path = source_dir / "state.yaml" + state_path.write_text( + state_path.read_text(encoding="utf-8") + .replace("status: todo", "status: split-required") + .replace("claimed_by: null", "claimed_by: macbook-twork") + .replace("claimed_at: null", "claimed_at: 2026-07-18T10:00:00+04:00") + .replace("claim_order: null", "claim_order: 1") + .replace("phase: null", "phase: split-required") + .replace( + "inbox_receipt:", + "model: gpt-5.6-sol\ninbox_receipt:", + ), + encoding="utf-8", + ) + work = source_dir / "work" + work.mkdir() + (work / "owned-paths.txt").write_text( + "tracked.txt\n", encoding="utf-8", + ) + for name in ("base", "run"): + git( + source, + "update-ref", + workspace.source_task_ref(source_task, name), + "HEAD", + ) + (source / "tracked.txt").write_text("carried\n", encoding="utf-8") + snapshot = workspace.source_worktree_snapshot( + config, main, source_task, + ) + (work / "carried-work.json").write_text( + json.dumps({"implementation": "retained", **snapshot}) + "\n", + encoding="utf-8", + ) + git(main, "add", f"tasks/{source_task}") + git(main, "commit", "-m", f"Split-required {source_task}") + git(slot, "merge", "--ff-only", "master") + + replacements = [ + "2026/07/20/adopt-active-task-implementation", + "2026/07/20/finish-active-task-integration", + ] + receipt = "receipts/2026/07/20/split-active-task.md" + for task_id in replacements: + directory = slot / "tasks" / task_id + directory.mkdir(parents=True) + (directory / "task.md").write_text( + f"# {task_id.rsplit('/', 1)[-1]}\n", + encoding="utf-8", + ) + (directory / "state.yaml").write_text( + f"""status: todo +type: implement +created: 2026-07-20 +project: null +depends_on: [] +claimed_by: null +claimed_at: null +claim_order: null +lease_until: null +phase: null +inbox_receipt: {receipt} +""", + encoding="utf-8", + ) + receipt_path = slot / receipt + receipt_path.parent.mkdir(parents=True) + receipt_path.write_text( + "\n".join([source_task, *replacements]) + "\n", + encoding="utf-8", + ) + with mock.patch.object( + workspace, "worktree_config", return_value=config, + ): + result = run_command( + workspace.command_split_publish, + source_task=source_task, + replacements=replacements, + receipt=receipt, + implementation_carrier=replacements[0], + paths=[ + f"tasks/{source_task}", + *(f"tasks/{task_id}" for task_id in replacements), + receipt, + ], + ) + self.assertEqual(result["status"], "split") + self.assertTrue((main / "tasks" / source_task / "split.yaml").is_file()) + self.assertFalse((main / "tasks" / source_task / "state.yaml").exists()) + resolved = workspace.resolve_task( + main, workspace.load_states(main), source_task, + ) + self.assertEqual(resolved["status"], "split") + self.assertEqual(resolved["split_into"], replacements) + carrier = workspace.load_states(main)[replacements[0]] + self.assertEqual(carrier["claimed_by"], "macbook-twork") + self.assertEqual(carrier["carried_from"], source_task) + self.assertEqual((source / "tracked.txt").read_text(), "carried\n") + + with mock.patch.object( + workspace, "worktree_config", return_value=config, + ): + started = run_command( + workspace.command_start, + task=replacements[0], + require=[], + ) + self.assertEqual(started["status"], "in-progress") + self.assertIsNotNone(workspace.resolved_ref( + source, + workspace.source_task_ref(replacements[0], "base"), + )) + self.assertIsNone(workspace.resolved_ref( + source, + workspace.source_task_ref(source_task, "base"), + )) + if __name__ == "__main__": unittest.main() diff --git a/.claude/ai-workflow-adapter.md b/.claude/ai-workflow-adapter.md index c7d2c9380f..80798cfd17 100644 --- a/.claude/ai-workflow-adapter.md +++ b/.claude/ai-workflow-adapter.md @@ -13,10 +13,10 @@ This file adapts harness mechanics and removes unnecessary text normalization. prompt containing exact repository, task, artifact, and input paths. Do not rely on the parent conversation being inherited. - Tell every disposable phase leaf Agent not to delegate and never to commit. - Publication-owning orchestrators — `process-inbox`, `perform-task`, discovered - routing, and pending-task consolidation — follow the shared workflow's exact - helper, commit, and publication contract instead. Preserve its single-writer - and one-stateful-performer constraints. + Publication-owning orchestrators — `process-inbox`, `perform-task`, split + routing, discovered routing, and pending-task consolidation — follow the + shared workflow's exact helper, commit, and publication contract instead. + Preserve its single-writer and one-stateful-performer constraints. - Every phase leaf and the performer inherit the parent model, as the shared workflow says. Do not pass a model override on the Agent call: its family aliases already resolve to the newest permitted model of that family, so an @@ -49,9 +49,9 @@ This file adapts harness mechanics and removes unnecessary text normalization. - If the first real leaf Agent is rejected before work begins because nested delegation is unavailable, use the shared same-session fallback. Do not treat mere presence of the Agent tool as a successful delegation probe. -- Whenever an Agent is asked to run `process-inbox`, `perform-task`, - discovered-task routing, or pending-task consolidation — the orchestrating - roles — explicitly tell it to read this adapter completely before the +- Whenever an Agent is asked to run `process-inbox`, `perform-task`, split-task + routing, discovered-task routing, or pending-task consolidation — the + orchestrating roles — explicitly tell it to read this adapter completely before the applicable shared skill or reference. Do NOT tell leaf phase agents to read this adapter: their phase prompts are self-contained and already carry the leaf rules (no delegation, no commits, progress and reply contracts); an diff --git a/.claude/commands/continue.md b/.claude/commands/continue.md index adaccf9f9e..973526e8b5 100644 --- a/.claude/commands/continue.md +++ b/.claude/commands/continue.md @@ -19,6 +19,14 @@ follow-up requires its affirmative independence test. Continue until that frozen-and-derived batch reaches the scheduler's normal stop condition or a global hard stop. +Apply the shared `split-required` result and routing phase. A performer publishes +that state; this parent launches one fresh foreground split Agent that reads the +complete shared split reference, creates and publishes the replacements, and +retires the source task. Replace the source id in the frozen batch with those +replacements. When retained implementation exists, start the checkout-owned +carrier first so the helper transfers its sealed source state; do not reset or +checkpoint that work. + Apply the shared source-lineage gate in this parent scheduler. Before batch freeze, a missing approved prerequisite pauses through `AskUserQuestion` after the exact branch and compatible-branch report. After freeze but before Phase 1, @@ -35,10 +43,10 @@ scheduler context, and require it to read the complete shared consolidation reference. Never run consolidation inside the performer or discovery-routing Agent, and defer it while the active task owns dirty local phase state. -Every inbox worker, performer, discovered-task routing Agent, and pending-task -consolidation Agent must be told in its initial prompt to read -`.claude/ai-workflow-adapter.md` completely before the applicable shared skill -or reference. Use the Agent tool for those workers; do not start Claude +Every inbox worker, performer, split-routing Agent, discovered-task routing +Agent, and pending-task consolidation Agent must be told in its initial prompt +to read `.claude/ai-workflow-adapter.md` completely before the applicable shared +skill or reference. Use the Agent tool for those workers; do not start Claude subprocesses through Bash. Arguments, when present, are natural-language hints for new shared work; their diff --git a/.claude/commands/perform-task.md b/.claude/commands/perform-task.md index 0ff2c3f54a..c27dd7e537 100644 --- a/.claude/commands/perform-task.md +++ b/.claude/commands/perform-task.md @@ -11,6 +11,9 @@ the Claude adapter's delegation and text-handling substitutions. Resolve, start or resume, implement, verify, and publish only the named task. Do not continue with other queue work, route discoveries, or consolidate pending tasks afterward; those post-result phases belong to the `continue` scheduler. +If assessment or review convergence proves the task intrinsically broad, +publish the shared `split-required` result with retained source state and stop; +do not create its replacement tasks in this command. Apply the shared pipeline's conditional `[ai] ` commit-subject rule exactly; decide it per commit, require it only when all changes and the purpose are diff --git a/.gitignore b/.gitignore index 739b6d3703..320c160d09 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,8 @@ stage /snap/gui/*.png /snap/gui/*.desktop /snap/plugins/__pycache__ +__pycache__/ +*.pyc /Telegram/*.user.* *.txt.user diff --git a/.grok/ai-workflow-adapter.md b/.grok/ai-workflow-adapter.md index 33b864dae3..aa4ffada1c 100644 --- a/.grok/ai-workflow-adapter.md +++ b/.grok/ai-workflow-adapter.md @@ -32,9 +32,9 @@ resumability, and AI publication. This file adapts harness mechanics. consolidation, and phase leaves. Do not restrict `capability_mode`; those workers need shell plus writes. - Tell every disposable phase leaf not to delegate and never to commit. - Publication-owning orchestrators — `process-inbox`, `perform-task`, - discovered routing, and pending-task consolidation — follow the shared - workflow's exact helper, commit, and publication contract. Preserve its + Publication-owning orchestrators — `process-inbox`, `perform-task`, split + routing, discovered routing, and pending-task consolidation — follow the + shared workflow's exact helper, commit, and publication contract. Preserve its single-writer and one-stateful-performer constraints. - When a blocking call returns without its required artifact, retry that disposable worker once in a fresh `spawn_subagent` with more specific @@ -57,8 +57,8 @@ nested-delegation probe. This session is the scheduler only. Do not plan or implement Telegram changes here. -Spawn each inbox worker, performer, discovery-routing worker, and -pending-task consolidation worker as a blocking `spawn_subagent`. Tell +Spawn each inbox worker, performer, split-routing worker, discovery-routing +worker, and pending-task consolidation worker as a blocking `spawn_subagent`. Tell every one of those workers, in its initial prompt, to read this adapter completely before the applicable shared skill or reference. @@ -133,8 +133,9 @@ invocation resume it. The only scheduler state that is not already on disk is the frozen batch (`invocation_mode`, `initial_batch_task_ids`, `batch_task_ids`, -`discovered_task_ids`, `attempted_blocked`, consolidation records). After -a compact, recover those lists from the compact summary and the last +`discovered_task_ids`, `attempted_blocked`, consolidation records, and ordered +split records with carrier ids). After a compact, recover those lists from the +compact summary and the last scheduler notes. Do not take a fresh queue snapshot and freeze a new batch. A later queue id that was never in this invocation's batch stays out, exactly as the shared skill says. diff --git a/.grok/commands/continue.md b/.grok/commands/continue.md index c56384db10..dd2b92ca2c 100644 --- a/.grok/commands/continue.md +++ b/.grok/commands/continue.md @@ -21,6 +21,14 @@ until that frozen-and-derived batch reaches the scheduler's normal stop condition or a global hard stop. Do not stop on your own because a compact is approaching. +Apply the shared `split-required` result and routing phase. A performer publishes +that state; this parent launches one fresh blocking split worker that reads the +complete shared split reference, creates and publishes the replacements, and +retires the source task. Replace the source id in the frozen batch with those +replacements. When retained implementation exists, start the checkout-owned +carrier first so the helper transfers its sealed source state; do not reset or +checkpoint that work. + Apply the shared skill's pending-task consolidation phase too. At each eligible clean AI-slot boundary, recover an older `pending_consolidations` marker or consolidate newly routed tasks in one fresh blocking @@ -30,9 +38,9 @@ the complete shared consolidation reference. Never run consolidation inside the performer or discovery-routing worker, and defer it while the active task owns dirty local phase state. -Every inbox worker, performer, discovered-task routing worker, and -pending-task consolidation worker must be told in its initial prompt to -read `.grok/ai-workflow-adapter.md` completely before the applicable shared +Every inbox worker, performer, split-routing worker, discovered-task routing +worker, and pending-task consolidation worker must be told in its initial prompt +to read `.grok/ai-workflow-adapter.md` completely before the applicable shared skill or reference. Use `spawn_subagent` with `background: false` for those workers. A continue-spawned performer is at depth 1 and must run every phase leaf as a same-session checklist. Do not start Grok subprocesses diff --git a/.grok/commands/perform-task.md b/.grok/commands/perform-task.md index 3153e61d80..313e94a320 100644 --- a/.grok/commands/perform-task.md +++ b/.grok/commands/perform-task.md @@ -12,6 +12,9 @@ substitutions. Resolve, start or resume, implement, verify, and publish only the named task. Do not continue with other queue work, route discoveries, or consolidate pending tasks afterward; those post-result phases belong to the `continue` scheduler. +If assessment or review convergence proves the task intrinsically broad, +publish the shared `split-required` result with retained source state and stop; +do not create its replacement tasks in this command. Apply the shared pipeline's conditional `[ai] ` commit-subject rule exactly; decide it per commit, require it only when all changes and the purpose are