mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2026-08-13 15:03:36 +00:00
[ai] Test through locked macOS sessions.
This commit is contained in:
@@ -250,7 +250,12 @@ How TEST verifies it (numbers over eyes):
|
||||
|
||||
The overlay is ad-hoc, authored fresh against the CURRENT implementation, injected at the
|
||||
highest level that still exercises the change (often a direct data-layer call like
|
||||
`item->applyEdition(...)` rather than a faked MTP response). It must:
|
||||
`item->applyEdition(...)` rather than a faked MTP response). It is also a complete runtime driver
|
||||
when external desktop control is unavailable. Drive the whole task-specific flow inside the Debug
|
||||
binary by invoking application actions or posting Qt input events on the event loop, waiting for
|
||||
observable state, logging assertions, capturing the rendered target in-process, and quitting. A
|
||||
locked macOS session does not reduce required coverage and is never a testing blocker. The overlay
|
||||
must:
|
||||
|
||||
- Live entirely inside `#ifdef _DEBUG` blocks.
|
||||
- Pick a **test strategy** and record it in the spec:
|
||||
@@ -267,6 +272,10 @@ highest level that still exercises the change (often a direct data-layer call li
|
||||
it) so the target is unambiguously in frame at usable resolution. A full-window grab that leaves
|
||||
the target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target
|
||||
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass.
|
||||
- When the desktop is locked or an OS screenshot is unavailable, capture from inside the process
|
||||
with `QWidget::grab()` or a renderer-owned image after layout and paint have completed. A widget or
|
||||
test-window grab plus logged geometry is primary visual evidence; never wait for unlock merely to
|
||||
obtain a desktop screenshot.
|
||||
- **Lay down the oracle's references.** Save every applicable independent reference beside the
|
||||
crop. Exact asset work saves OLD and intended-NEW art as `<name>_{old,new}.png`. Without target
|
||||
artwork, save the baseline/reference-component crop when available and log the contract anchors,
|
||||
@@ -332,6 +341,9 @@ actually land; likewise for screenshots.
|
||||
|
||||
## Build & run discipline
|
||||
|
||||
- On macOS, a locked graphical session disables external UI driving only. Launch `EXE` normally,
|
||||
run the in-binary overlay flow, collect its logs and widget/window grabs, assess them, and clean up.
|
||||
Do not try to unlock the session and do not return BLOCKED because the lock screen is present.
|
||||
- Build with `BUILD`. A single changed TU compiles fast; only the overlay-touched files + link
|
||||
rebuild between rounds. Proactive path-scoped cleanup may run before the build. If the build reports
|
||||
`LNK1104`, `C1041`, access denied, or file in use, follow `AGENTS.md`: stop immediately, do not
|
||||
|
||||
@@ -26,12 +26,17 @@ python3 .agents/skills/process-inbox/scripts/workspace.py queue
|
||||
Use `python` or `py -3` when appropriate. Save `checkout_tag`, `ai_main`,
|
||||
`slot_worktree`, and `source_root` from its JSON. Read `ai_main/AGENTS.md`.
|
||||
|
||||
Stop before mutating anything when `violations` is nonempty, AI master is
|
||||
dirty, or AI slot changes are not wholly inside its one active task. Never
|
||||
clean, stash, reset, or absorb unrelated changes. Dirty task-scoped AI files
|
||||
are the active checkout's local resumable phase state and are expected.
|
||||
Unpublished clean AI slot commits are incomplete publication; run the helper's
|
||||
`publish` command before selecting work.
|
||||
Stop before mutating anything when `violations` is nonempty or AI master is
|
||||
dirty. Dirty task-scoped AI files are the active checkout's local resumable
|
||||
phase state and are expected; resume from them, never discard them. Other
|
||||
uncommitted slot changes under `tasks/`, `projects/`, or `receipts/` are
|
||||
disposable leftovers of an interrupted worker; the ignored inbox snapshot and
|
||||
published task results retain every durable input needed to redo them.
|
||||
Restore those exact tracked paths to the slot branch head, delete their
|
||||
untracked files, and continue. Stop instead of cleaning when any other slot
|
||||
path changed, and never clean the main worktree or stash anywhere.
|
||||
Unpublished clean AI slot commits are incomplete publication; run the
|
||||
helper's `publish` command before selecting work.
|
||||
|
||||
The canonical lifecycle is deliberately small:
|
||||
|
||||
@@ -56,8 +61,11 @@ artifacts; it is never automatic scheduler behavior.
|
||||
## Interpret scope hints
|
||||
|
||||
Treat text after `$continue` or `/continue` as optional natural-language
|
||||
priority guidance. It may prefer tasks from one receipt, project, or explicit
|
||||
list, but it does not reserve a batch. Start only one shared task at a time,
|
||||
guidance for new shared work. Its own wording decides its strength. A
|
||||
preference such as "payments tasks first" only reorders selection; the run
|
||||
still drains every eligible task. A restriction such as "only the payments
|
||||
tasks" limits new shared work to matching tasks and ends the run when none
|
||||
remain. Neither form reserves a batch. Start only one shared task at a time,
|
||||
finish or exceptionally block it, refresh canonical state, then choose again.
|
||||
|
||||
Always resume this checkout's `in-progress` task and previously blocked work
|
||||
@@ -69,6 +77,14 @@ asks to stop or reassign it.
|
||||
Create an empty invocation-local `attempted_blocked` set, then repeat. Refresh
|
||||
queue JSON after every delegated operation and state transition.
|
||||
|
||||
Before publishing any new canonical `Start` commit, require a startable
|
||||
environment: a clean Telegram source checkout with clean submodules and no
|
||||
unrelated untracked files, plus an existing
|
||||
`out/Debug/test_TelegramForcePortable` golden account. A failed check is a
|
||||
global hard stop before claiming; never reserve shared work this checkout
|
||||
cannot immediately run. Resuming and retrying already-owned work keeps the
|
||||
performer's own preflight rules instead.
|
||||
|
||||
### 1. Resume active work
|
||||
|
||||
If this checkout has an `in-progress` task, select it and spawn one performer.
|
||||
@@ -126,10 +142,12 @@ ready work.
|
||||
|
||||
### 5. Start shared work
|
||||
|
||||
Otherwise select the first ready unclaimed `todo` task matching the priority
|
||||
hint, or the first ready task in normal queue order. Start it with the same
|
||||
helper command. `start` atomically assigns and activates the task, then
|
||||
publishes its canonical `Start` commit before source work begins.
|
||||
Otherwise select the first ready unclaimed `todo` task the hint prefers. Under
|
||||
a restrictive hint consider only matching tasks. With no hint, or when a mere
|
||||
preference has no matching ready task left, select the first ready task in
|
||||
normal queue order. Start it with the same helper command. `start` atomically
|
||||
assigns and activates the task, then publishes its canonical `Start` commit
|
||||
before source work begins.
|
||||
|
||||
A concurrent start may mean another checkout won the task. Never overwrite
|
||||
shared state; refresh and choose again.
|
||||
@@ -141,12 +159,26 @@ Stop when the inbox is empty and none of these exist:
|
||||
- this checkout's active task;
|
||||
- a ready blocked task not attempted in this invocation;
|
||||
- a ready legacy reserved task;
|
||||
- a ready unclaimed task matching the invocation's priority scope.
|
||||
- a ready unclaimed task this invocation may still start: any under no hint or
|
||||
a preference, only matching ones under a restrictive hint.
|
||||
|
||||
Work owned by another checkout does not keep this run alive. A blocked task
|
||||
already attempted in this invocation remains visible in the final summary but
|
||||
does not cause a busy loop.
|
||||
|
||||
Immediately before this normal stop, run the housekeeping command once:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/process-inbox/scripts/workspace.py archive-stale
|
||||
```
|
||||
|
||||
It publishes one canonical `Archive <slug>` commit for every project whose
|
||||
tasks are all approved and whose newest task is older than its threshold,
|
||||
after rewriting the project's relative links for the deeper path. Skip this
|
||||
housekeeping on any global hard stop. Only routing new work to an archived
|
||||
project restores it, through the helper's `unarchive` command; nothing
|
||||
un-archives on a timer.
|
||||
|
||||
## Spawn one performer
|
||||
|
||||
Spawn exactly one performer for the selected task with `fork_turns: "none"`
|
||||
@@ -185,6 +217,9 @@ failure stops the loop.
|
||||
The missing `test_TelegramForcePortable` golden account is the only
|
||||
portable-folder global stop. All live/real folder combinations must be
|
||||
reconciled by `perform-task` according to the shared test-loop protocol.
|
||||
Computer Use being unavailable because macOS is locked is never a scheduler
|
||||
stop; the performer must continue with the in-binary overlay driver and
|
||||
artifact-based assessment.
|
||||
|
||||
## Route discovered follow-ups
|
||||
|
||||
@@ -209,6 +244,8 @@ conflict or unavailable-remote slot commit and stop.
|
||||
|
||||
Return one compact summary: inbox receipt if processed, tasks approved,
|
||||
exceptionally blocked tasks with exact unverified behavior and retry status,
|
||||
tasks started or left queued, routed discoveries, elapsed time, and why the
|
||||
loop stopped. Make any global hard stop or unsafe state unmistakable. Never
|
||||
include source or AI commit hashes; task ids are the only durable locators.
|
||||
tasks started or left queued, routed discoveries, archived projects, any
|
||||
discarded interrupted-worker leftovers, elapsed time, and why the loop
|
||||
stopped. Make
|
||||
any global hard stop or unsafe state unmistakable. Never include source or AI
|
||||
commit hashes; task ids are the only durable locators.
|
||||
|
||||
@@ -98,6 +98,11 @@ canonical `Block <full-task-id>` commit. Agent interruption, tool loss, and
|
||||
global environment stops leave the task `in-progress` with its task-scoped
|
||||
local state intact for the next invocation.
|
||||
|
||||
A locked macOS session is not an environment stop or verification blocker.
|
||||
Skip interactive Computer Use and complete the same coverage through the
|
||||
in-binary overlay: drive the flow, log/assert, capture widgets or windows,
|
||||
quit, and assess the saved artifacts.
|
||||
|
||||
Do not report success from a source commit alone. The final AI commit must be
|
||||
canonical. Retry ordinary concurrent-master publication races until success.
|
||||
On a semantic conflict, unsafe checkout, or unreachable remote, preserve
|
||||
|
||||
@@ -9,6 +9,7 @@ authoritative.
|
||||
|
||||
- [Driver policy](#driver-policy)
|
||||
- [Capability gate](#capability-gate)
|
||||
- [Locked macOS override](#locked-macos-override)
|
||||
- [Exact-app gate](#exact-app-gate)
|
||||
- [Hybrid handshake](#hybrid-handshake)
|
||||
- [Safety envelope](#safety-envelope)
|
||||
@@ -23,7 +24,8 @@ Resolve one policy from the user's request and pass it to the task-runner:
|
||||
focus, scrolling, dragging, menus, windowing, or native UI materially improves coverage.
|
||||
- `overlay-only` — never use Computer Use.
|
||||
- `required` — use hybrid driving for the named flow; if it cannot run safely, return
|
||||
`BLOCKED(test)` with the exact missing interaction rather than weakening the oracle.
|
||||
`BLOCKED(test)` with the exact missing interaction rather than weakening the oracle, except for
|
||||
the locked-macOS override below.
|
||||
|
||||
For each check, select `Driver: overlay` or `Driver: hybrid`. Keep overlay-only for internal state,
|
||||
data, exact text, and geometry that the in-app harness can exercise deterministically. Select hybrid
|
||||
@@ -63,14 +65,37 @@ not arrive through `fork_turns: "none"`; the explicit path is the handoff.
|
||||
Do not infer capability from an installed directory or config entry. An installed plugin may be
|
||||
disabled by policy, lack its runtime tool, support a different platform, or still need app/OS
|
||||
approval. In `auto`, fall back to the prewritten overlay action. In `required`, or when no equivalent
|
||||
overlay can exercise the physical interaction, return `BLOCKED(test)` with the canonical mapping below.
|
||||
Missing capability or permission is never an implementation bug.
|
||||
overlay can exercise the physical interaction, return `BLOCKED(test)` with the canonical mapping
|
||||
below unless the locked-macOS override applies. Missing capability or permission is never an
|
||||
implementation bug.
|
||||
|
||||
Computer Use runs in the foreground on Windows. Use it only on an unlocked, reserved active desktop
|
||||
or isolated VM. Treat local pointer or keyboard interference, a focus steal, or a window switch as a
|
||||
contaminated run and restart within `MAX_TEST_RUNS`. Treat WSL/Linux/headless as unavailable unless
|
||||
the current host explicitly exposes a supported desktop adapter for the exact test app.
|
||||
|
||||
## Locked macOS override
|
||||
|
||||
When macOS reports the graphical session locked, including
|
||||
`CGSSessionScreenIsLocked=Yes`, do not wait for unlock, repeat capability checks, ask the human to
|
||||
unlock, count repeated lock checks toward a blocked goal, or return `BLOCKED(test)`. Do not call
|
||||
Computer Use against the lock screen. Force every planned check to `Driver: overlay`, regardless of
|
||||
whether the original policy was `auto` or `required`, and continue the ordinary build/run/assess
|
||||
loop. Skip the exact-app gate and hybrid handshake entirely for that run.
|
||||
|
||||
Treat the in-binary overlay as a complete driver, not merely supplemental instrumentation. Extend it
|
||||
until it performs the whole task-specific flow on the Qt event loop: create or inject the fixture,
|
||||
invoke the same application actions or post the needed Qt input events, wait on observable
|
||||
conditions, log actual values and assertions, grab the target widget or top-level test window from
|
||||
inside the process, save artifacts, emit `TEST_COMPLETE`, and quit. Prefer `QWidget::grab()` or a
|
||||
renderer-owned image over an OS desktop capture, which may show only the lock screen. Assess those
|
||||
logs, measurements, and saved images exactly as in any other overlay run.
|
||||
|
||||
Record the lock in `computer-use-capability.md` as the reason for selecting overlay-only and report
|
||||
`UI-Driver: overlay`. Lock state is neither missing verification nor a global environment stop. If
|
||||
the first overlay design depended on an external gesture, redesign that action inside the binary
|
||||
rather than treating the locked session as evidence that the task cannot be tested.
|
||||
|
||||
## Exact-app gate
|
||||
|
||||
Keep process and account ownership in the ordinary runner:
|
||||
@@ -146,6 +171,8 @@ Whenever a planned hybrid check is unavailable before a run, write
|
||||
`<TASK_DIR>/computer-use-capability.md` with the policy, host, active skill path or `none`, runtime
|
||||
tool status, OS/app-approval status, exact-app identity result, fallback decision, and reason.
|
||||
|
||||
- Locked macOS always continues overlay-only under the override above, including for `required`.
|
||||
Never map screen lock to `BLOCKED(test)` or `UI-Driver: hybrid-unavailable`.
|
||||
- `auto` with an equivalent fallback continues overlay-only. Record `UI-Driver: overlay` and cite the
|
||||
capability report plus overlay evidence; capability failure is not a blocker.
|
||||
- `required`, or `auto` without an equivalent fallback, records `STATUS: BLOCKED`,
|
||||
|
||||
@@ -80,8 +80,10 @@ Before planning or editing:
|
||||
3. Require the prepared portable test account. Its absence is a global hard
|
||||
stop before implementation.
|
||||
4. Verify a usable Debug executable/build tree, safe path-scoped process
|
||||
control, safe portable-folder operations, and an app-run display. Computer
|
||||
Use remains optional unless policy is `required`.
|
||||
control, safe portable-folder operations, and the ability to launch and
|
||||
render the in-binary test flow. A locked macOS session disables Computer Use
|
||||
only; it does not fail this preflight or block testing, even when policy was
|
||||
`required`.
|
||||
5. For a new run require a clean tracked Telegram worktree, clean submodules,
|
||||
and no unrelated untracked files, then initialize local recovery state:
|
||||
|
||||
@@ -279,6 +281,11 @@ rules, with these external-task safety adaptations:
|
||||
clean tip before reapplying the overlay.
|
||||
- Reapply with `git apply --3way`; re-author a conflicting hunk from `test.md`
|
||||
rather than leaving conflict markers.
|
||||
- On locked macOS, force overlay-only testing without waiting or blocking.
|
||||
Encode the complete interaction inside the Debug binary using application
|
||||
actions or Qt events, log assertions and geometry, capture widgets/windows
|
||||
in-process, save the artifacts, and quit. Do not require an OS-level desktop
|
||||
screenshot or interactive Computer Use evidence.
|
||||
- Missing `test_TelegramForcePortable` is the only portable-account setup
|
||||
blocker. If live exists without real, move live to real. If live and real
|
||||
both exist, recursively delete live completely. Then deep-copy golden to
|
||||
@@ -387,6 +394,9 @@ planner into new unclaimed dated tasks before selecting more shared work.
|
||||
environment problem stops the current invocation.
|
||||
- A file-lock build error always stops immediately and asks the human to close
|
||||
this checkout's Telegram/debugger.
|
||||
- A locked macOS session and the resulting unavailable Computer Use driver
|
||||
never stop or block the task; continue with the complete in-binary overlay
|
||||
flow.
|
||||
- Missing optional screenshots or mockups never block.
|
||||
- Never silently pass unverified behavior. Surface every blocked or partially
|
||||
verified task with exact `work/test.md`, `work/result.md`, and evidence paths.
|
||||
|
||||
@@ -46,7 +46,8 @@ Read these before planning:
|
||||
|
||||
- source checkout `AGENTS.md`;
|
||||
- `ai_main/AGENTS.md`;
|
||||
- existing `projects/*/project.md` and task states relevant to the request;
|
||||
- existing `projects/*/project.md`, including `projects/archive/`, and task
|
||||
states relevant to the request;
|
||||
- the transaction's `inbox.md` and every file it references.
|
||||
|
||||
Use one disposable leaf planner when the harness supports delegation; instruct
|
||||
@@ -67,6 +68,18 @@ domain projects. Group requests into one task only when they form one cohesive,
|
||||
independently testable behavior. Split work until every task is implementable
|
||||
in one pass and has an exact observable acceptance result.
|
||||
|
||||
Project slugs are unique across `projects/` and `projects/archive/`. When a
|
||||
request belongs to an archived project, restore it before routing to it:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/process-inbox/scripts/workspace.py unarchive \
|
||||
--project <slug>
|
||||
```
|
||||
|
||||
The helper moves the project back to `projects/<slug>`, rewrites its relative
|
||||
links, and leaves the restored files staged for this transaction's commit.
|
||||
Never point a task at a path under `projects/archive/`.
|
||||
|
||||
Briefly inspect Telegram source when needed to understand scope and testable
|
||||
seams. Do not plan implementation internals and do not modify the source tree.
|
||||
|
||||
@@ -157,6 +170,7 @@ Before committing, verify:
|
||||
- every new task has `task.md`, valid `state.yaml`, and a falsifiable
|
||||
acceptance result;
|
||||
- every task link, dependency, and copied input exists;
|
||||
- no task or project reference points into `projects/archive/`;
|
||||
- no raw inbox path, `.local/`, browser profile, portable account, credential,
|
||||
complete run directory, or complete build log is tracked;
|
||||
- no Telegram or AI commit hash is copied into a task, project, or receipt;
|
||||
|
||||
@@ -21,6 +21,9 @@ COMMIT_HASH_PATTERN = re.compile(
|
||||
r"(?i)\b(?:commit|revision|sha(?:-1)?)\b[^\r\n]{0,32}(?<!#)\b[0-9a-f]{7,64}\b"
|
||||
)
|
||||
LEGACY_COMMIT_FIELDS = ("Task-Base-SHA:", "Implementation-SHA:")
|
||||
PROJECT_ARCHIVE_DIR = "archive"
|
||||
PROJECT_LINK_PATTERN = re.compile(r"\]\(\.\./\.\./(?!\.\./)")
|
||||
ARCHIVED_PROJECT_LINK_PATTERN = re.compile(r"\]\(\.\./\.\./\.\./")
|
||||
STATE_FIELD_ORDER = [
|
||||
"status",
|
||||
"created",
|
||||
@@ -993,6 +996,109 @@ def command_publish(args):
|
||||
print(json.dumps({"published": bool(published)}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def rewrite_project_links(directory, pattern, replacement):
|
||||
for path in sorted(directory.rglob("*.md")):
|
||||
data = path.read_bytes()
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
raise WorkspaceError(f"Refusing to preserve a UTF-8 BOM in {path}")
|
||||
text = data.decode("utf-8")
|
||||
replaced = pattern.sub(replacement, text)
|
||||
if replaced != text:
|
||||
path.write_bytes(replaced.encode("utf-8"))
|
||||
|
||||
|
||||
def project_last_activity(states):
|
||||
activity = {}
|
||||
unfinished = set()
|
||||
for task in states.values():
|
||||
project = task["project"]
|
||||
if project is None:
|
||||
continue
|
||||
try:
|
||||
created = datetime.date.fromisoformat(task["created"])
|
||||
except ValueError as error:
|
||||
raise WorkspaceError(
|
||||
f"Invalid created date in {task['id']}: {task['created']!r}"
|
||||
) from error
|
||||
if project not in activity or created > activity[project]:
|
||||
activity[project] = created
|
||||
if task["status"] != "approved":
|
||||
unfinished.add(project)
|
||||
return activity, unfinished
|
||||
|
||||
|
||||
def command_archive_stale(args):
|
||||
if args.days < 1:
|
||||
raise WorkspaceError("The archive threshold must be at least one day")
|
||||
config = worktree_config(args, create=True)
|
||||
sync_canonical(config)
|
||||
slot = Path(config["slot_worktree"])
|
||||
activity, unfinished = project_last_activity(load_states(slot))
|
||||
cutoff = datetime.date.today() - datetime.timedelta(days=args.days)
|
||||
projects = slot / "projects"
|
||||
candidates = sorted(
|
||||
entry.name for entry in projects.iterdir()
|
||||
if entry.is_dir() and entry.name != PROJECT_ARCHIVE_DIR
|
||||
) if projects.is_dir() else []
|
||||
archived = []
|
||||
published = False
|
||||
for slug in candidates:
|
||||
if slug in unfinished or slug not in activity or activity[slug] > cutoff:
|
||||
continue
|
||||
target = projects / PROJECT_ARCHIVE_DIR / slug
|
||||
if target.exists():
|
||||
raise WorkspaceError(f"The archive already contains a project: {slug}")
|
||||
target.parent.mkdir(exist_ok=True)
|
||||
run_git(
|
||||
slot,
|
||||
"mv",
|
||||
f"projects/{slug}",
|
||||
f"projects/{PROJECT_ARCHIVE_DIR}/{slug}",
|
||||
)
|
||||
rewrite_project_links(target, PROJECT_LINK_PATTERN, "](../../../")
|
||||
published = commit_paths(
|
||||
config,
|
||||
[f"projects/{PROJECT_ARCHIVE_DIR}/{slug}"],
|
||||
f"Archive {slug}",
|
||||
)
|
||||
archived.append(slug)
|
||||
print(json.dumps({
|
||||
"archived": archived,
|
||||
"days": args.days,
|
||||
"published": bool(published),
|
||||
}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def command_unarchive(args):
|
||||
slug = args.project
|
||||
if slug == PROJECT_ARCHIVE_DIR or not TAG_PATTERN.fullmatch(slug):
|
||||
raise WorkspaceError(f"Invalid project slug: {slug!r}")
|
||||
config = worktree_config(args, create=True)
|
||||
slot = Path(config["slot_worktree"])
|
||||
archived = slot / "projects" / PROJECT_ARCHIVE_DIR / slug
|
||||
target = slot / "projects" / slug
|
||||
if not archived.is_dir():
|
||||
raise WorkspaceError(f"No archived project: {slug}")
|
||||
if target.exists():
|
||||
raise WorkspaceError(f"The project is already live: {slug}")
|
||||
run_git(
|
||||
slot,
|
||||
"mv",
|
||||
f"projects/{PROJECT_ARCHIVE_DIR}/{slug}",
|
||||
f"projects/{slug}",
|
||||
)
|
||||
rewrite_project_links(target, ARCHIVED_PROJECT_LINK_PATTERN, "](../../")
|
||||
run_git(slot, "add", "--", f"projects/{slug}")
|
||||
archive_root = slot / "projects" / PROJECT_ARCHIVE_DIR
|
||||
if archive_root.is_dir() and not any(archive_root.iterdir()):
|
||||
archive_root.rmdir()
|
||||
print(json.dumps({
|
||||
"project": slug,
|
||||
"published": False,
|
||||
"unarchived": True,
|
||||
}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def payload_entries(inbox):
|
||||
return sorted(
|
||||
(path for path in inbox.iterdir() if path.name != "backup"),
|
||||
@@ -1252,6 +1358,16 @@ def parse_args():
|
||||
add_common_arguments(publish)
|
||||
publish.set_defaults(handler=command_publish)
|
||||
|
||||
archive_stale = subparsers.add_parser("archive-stale")
|
||||
add_common_arguments(archive_stale)
|
||||
archive_stale.add_argument("--days", type=int, default=90)
|
||||
archive_stale.set_defaults(handler=command_archive_stale)
|
||||
|
||||
unarchive = subparsers.add_parser("unarchive")
|
||||
add_common_arguments(unarchive)
|
||||
unarchive.add_argument("--project", required=True)
|
||||
unarchive.set_defaults(handler=command_unarchive)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -15,6 +16,12 @@ import workspace
|
||||
TASK_ID = "2026/07/19/correct-recent-search-peer-actions"
|
||||
|
||||
|
||||
class FrozenDate(datetime.date):
|
||||
@classmethod
|
||||
def today(cls):
|
||||
return cls(2026, 7, 20)
|
||||
|
||||
|
||||
def task_state(status, claimed_by="macbook-twork"):
|
||||
return {
|
||||
"id": TASK_ID,
|
||||
@@ -61,6 +68,42 @@ inbox_receipt: receipts/2026/07/19/test.md
|
||||
return directory
|
||||
|
||||
|
||||
def write_project(slot, slug):
|
||||
directory = slot / "projects" / slug
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "project.md").write_text(f"# {slug}\n", encoding="utf-8")
|
||||
(directory / "tasks.md").write_text(
|
||||
"# Tasks\n\n- [Task](../../tasks/2026/01/10/some-task/task.md)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def write_project_task(slot, task_id, project, status, created):
|
||||
directory = slot / "tasks" / task_id
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "task.md").write_text(
|
||||
f"# {task_id.rsplit('/', 1)[-1]}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
claimed = status != "todo"
|
||||
(directory / "state.yaml").write_text(
|
||||
f"""status: {status}
|
||||
created: {created}
|
||||
project: {project}
|
||||
depends_on: []
|
||||
claimed_by: {"macbook-twork" if claimed else "null"}
|
||||
claimed_at: {f"{created}T10:00:00+04:00" if claimed else "null"}
|
||||
claim_order: {"1" if claimed else "null"}
|
||||
lease_until: null
|
||||
phase: {"complete" if status == "approved" else "null"}
|
||||
inbox_receipt: receipts/2026/01/10/test.md
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
@@ -71,6 +114,13 @@ def git(repo, *args):
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def git_repo(path):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
git(path, "init")
|
||||
git(path, "config", "user.name", "Workflow Test")
|
||||
git(path, "config", "user.email", "workflow@example.invalid")
|
||||
|
||||
|
||||
class WorkspaceTest(unittest.TestCase):
|
||||
def test_resolve_prefers_blocked_over_approved_history(self):
|
||||
blocked = task_state("blocked")
|
||||
@@ -337,6 +387,101 @@ inbox_receipt: receipts/2026/07/19/test.md
|
||||
workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "green"))
|
||||
)
|
||||
|
||||
def test_archive_stale_moves_only_old_fully_approved_projects(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
slot = Path(temporary)
|
||||
git_repo(slot)
|
||||
write_project(slot, "old-project")
|
||||
write_project(slot, "fresh-project")
|
||||
write_project(slot, "pending-project")
|
||||
write_project_task(
|
||||
slot, "2026/01/10/old-task", "old-project", "approved", "2026-01-10"
|
||||
)
|
||||
write_project_task(
|
||||
slot, "2026/07/19/fresh-task", "fresh-project", "approved", "2026-07-19"
|
||||
)
|
||||
write_project_task(
|
||||
slot, "2026/01/11/pending-task", "pending-project", "blocked", "2026-01-11"
|
||||
)
|
||||
git(slot, "add", ".")
|
||||
git(slot, "commit", "-m", "Seed projects")
|
||||
config = {
|
||||
"checkout_tag": "macbook-twork",
|
||||
"slot_worktree": str(slot),
|
||||
}
|
||||
with (
|
||||
mock.patch.object(workspace, "worktree_config", return_value=config),
|
||||
mock.patch.object(workspace, "sync_canonical"),
|
||||
mock.patch.object(workspace, "commit_paths", return_value=True) as commit,
|
||||
mock.patch.object(workspace.datetime, "date", FrozenDate),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
):
|
||||
workspace.command_archive_stale(SimpleNamespace(days=90))
|
||||
|
||||
archived = slot / "projects" / "archive" / "old-project"
|
||||
self.assertTrue(archived.is_dir())
|
||||
self.assertFalse((slot / "projects" / "old-project").exists())
|
||||
self.assertIn(
|
||||
"](../../../tasks/",
|
||||
(archived / "tasks.md").read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertTrue((slot / "projects" / "fresh-project").is_dir())
|
||||
self.assertTrue((slot / "projects" / "pending-project").is_dir())
|
||||
commit.assert_called_once()
|
||||
self.assertEqual(
|
||||
commit.call_args.args[1],
|
||||
["projects/archive/old-project"],
|
||||
)
|
||||
self.assertEqual(commit.call_args.args[2], "Archive old-project")
|
||||
|
||||
def test_archive_stale_without_candidates_reports_nothing(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
slot = Path(temporary)
|
||||
config = {
|
||||
"checkout_tag": "macbook-twork",
|
||||
"slot_worktree": str(slot),
|
||||
}
|
||||
out = io.StringIO()
|
||||
with (
|
||||
mock.patch.object(workspace, "worktree_config", return_value=config),
|
||||
mock.patch.object(workspace, "sync_canonical"),
|
||||
mock.patch.object(workspace, "commit_paths") as commit,
|
||||
contextlib.redirect_stdout(out),
|
||||
):
|
||||
workspace.command_archive_stale(SimpleNamespace(days=90))
|
||||
|
||||
commit.assert_not_called()
|
||||
self.assertIn('"archived": []', out.getvalue())
|
||||
|
||||
def test_unarchive_restores_links_and_stages_the_project(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
slot = Path(temporary)
|
||||
git_repo(slot)
|
||||
directory = slot / "projects" / "archive" / "old-project"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "project.md").write_text("# Old project\n", encoding="utf-8")
|
||||
(directory / "tasks.md").write_text(
|
||||
"# Tasks\n\n- [Task](../../../tasks/2026/01/10/some-task/task.md)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
git(slot, "add", ".")
|
||||
git(slot, "commit", "-m", "Seed archive")
|
||||
config = {"slot_worktree": str(slot)}
|
||||
with (
|
||||
mock.patch.object(workspace, "worktree_config", return_value=config),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
):
|
||||
workspace.command_unarchive(SimpleNamespace(project="old-project"))
|
||||
|
||||
restored = slot / "projects" / "old-project"
|
||||
self.assertIn(
|
||||
"](../../tasks/",
|
||||
(restored / "tasks.md").read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertFalse((slot / "projects" / "archive").exists())
|
||||
staged = git(slot, "diff", "--cached", "--name-only")
|
||||
self.assertIn("projects/old-project/tasks.md", staged)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -16,7 +16,8 @@ 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 scheduling or priority hints:
|
||||
Arguments, when present, are natural-language hints for new shared work; their
|
||||
wording decides whether they merely prioritize or strictly restrict selection:
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
|
||||
@@ -44,7 +44,8 @@ Resolve the task and read its context:
|
||||
|
||||
1. Run `python3 .agents/skills/process-inbox/scripts/workspace.py resolve --name "$ARGUMENTS"` (use the host's Python 3 command).
|
||||
2. Read the resolved task's `task.md` and `work/context.md` from the returned AI slot worktree.
|
||||
3. When `project` is non-null, also read `projects/<project>/project.md`.
|
||||
3. When `project` is non-null, also read `projects/<project>/project.md`, or
|
||||
`projects/archive/<project>/project.md` when the project has been archived.
|
||||
|
||||
This helps you distinguish between:
|
||||
- **Task-specific mistakes** — the agent misunderstood this particular feature's requirements or made a wrong choice within the specific problem. These are NOT documentation-worthy.
|
||||
|
||||
Reference in New Issue
Block a user