Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,10 @@ migration or design work they imply. What the behaviour *is* lives in the docs
- Mono modifiers only, no colour, at every width. There is no theme module.
- Text wraps, never truncates. One wrap engine (`edit::wrap_text`) feeds every surface;
`escaped_draft_rows` remains only for single-line Title/Thread editors.
- `src/ui/capture.rs` is unreachable from the binary and stays that way; creation
detail lives on the task page and the expanded quick-add draft owns the whole frame.
- The standalone capture screen has no production caller and stays that way, but the
module's field types (`CaptureField`, `CaptureScopeChoice`, `TITLE_REQUIRED_MESSAGE`)
are live and the file stays. Creation detail lives on the task page and the expanded
quick-add draft owns the whole frame.
- `Enter` on a stored step is `ToggleStep`, resolved at the keyboard boundary in
`src/app.rs` so the save baseline loads first.
- `map_edit` and `map_board_form_key` share `map_form_edit_key`. Save-recovery
Expand Down
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ this file names things.
- **thread token**: the `!t name` capture directive, sibling of `!p`; bare `!t` means
unthreaded. Stripped from the saved title.
- **unthreaded**: carrying no thread. Unthreaded tasks stay in the project's flat board.
- **base token**: the `!b branch` capture directive, sibling of `!p` / `!t` / `!a`. Sets
the task's dispatch base branch. Stripped from the saved title.
- **after token**: the `!w T12` capture directive ("wait for"), sibling of `!p` / `!t` /
`!a` / `!b`. Adds a prerequisite by task number (`!w 12` works too). Repeatable.
Stripped from the saved title.
- **after / before**: a task runs *after* the tasks it names by number and waits while any
of them is not done. Each unfinished prerequisite shows its waiters as *before*. When the
last prerequisite is done, a waiting `ready` task starts.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ run `tsk` in any terminal.

Define your agents once in `config.toml`, press `@` to assign a task, and press
`ctrl+s` to start it. Starting an assigned task dispatches it: tsk creates a Git
worktree and a Herdr workspace for the task and launches the agent there. When the
pull request is merged, `ctrl+d` completes the task and removes the worktree and
branch, never touching uncommitted or unmerged work. Dispatch runs in Herdr; on
Windows it is a preview.
worktree and a Herdr workspace for the task and launches the agent there.
`ctrl+d` marks the task done; on a live dispatch it offers the cleanup card. Cleanup
closes the workspace and removes the worktree, and deletes the branch only when it is
merged. It never touches uncommitted changes. Dispatch runs in Herdr; on Windows it
is a preview.

## Quickstart

Expand Down
52 changes: 48 additions & 4 deletions site/public/board-demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -3026,20 +3026,40 @@ import { parseCapture } from "./capture.js";

function captureDirectives(raw) {
const parts = String(raw).trim().split(/\s+/).filter(Boolean);
const tokens = new Set(["!p", "!t", "!a", "!b", "!w"]);
const directives = [];
for (let i = 0; i < parts.length; i += 1) {
if (parts[i] !== "!p" && parts[i] !== "!t") continue;
if (!tokens.has(parts[i])) continue;
directives.push(parts[i]);
if (parts[i + 1] && !parts[i + 1].startsWith("!")) {
directives.push(parts[i + 1]);
const next = parts[i + 1];
if (next && !tokens.has(next) && !next.startsWith("#")) {
directives.push(next);
i += 1;
}
}
return directives;
}

function refuseAfter(after) {
for (const number of after || []) {
const task = state.tasks.find((entry) => entry.number === number);
if (!task) return `T${number} is not on the board`;
if (task.status === "done") return `T${number} is already done`;
}
return "";
}

function expandQuickAdd() {
const parsed = parseCapture(state.draft, fallbackProject());
if (parsed.refuse) {
state.refuse = parsed.refuse;
return false;
}
const afterRefuse = refuseAfter(parsed.after);
if (afterRefuse) {
state.refuse = afterRefuse;
return false;
}
const now = clock();
state.capture = {
id: "capture-draft",
Expand All @@ -3049,6 +3069,9 @@ import { parseCapture } from "./capture.js";
steps: [],
thread: parsed.thread === undefined ? null : parsed.thread,
project: parsed.project,
assignee: parsed.assignee === undefined ? null : parsed.assignee,
base: parsed.base === undefined ? null : parsed.base,
after: [...(parsed.after || [])],
status: "open",
createdAt: now,
updatedAt: now,
Expand All @@ -3068,6 +3091,7 @@ import { parseCapture } from "./capture.js";
state.editDraft = "";
steps.bind(state.capture);
}
return true;
}

function collapseQuickAdd() {
Expand Down Expand Up @@ -3107,6 +3131,11 @@ import { parseCapture } from "./capture.js";
state.refuse = "title needed";
return false;
}
const afterRefuse = refuseAfter(capture.after);
if (afterRefuse) {
state.refuse = afterRefuse;
return false;
}
const owner = state.quickOwner;
const id = `n${state.nextId++}`;
const now = clock();
Expand All @@ -3115,6 +3144,9 @@ import { parseCapture } from "./capture.js";
id,
number: state.nextNumber++,
title,
assignee: capture.assignee ?? null,
base: capture.base ?? null,
after: [...(capture.after || [])],
steps: structuredClone(capture.steps),
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -3183,6 +3215,15 @@ import { parseCapture } from "./capture.js";
function saveDraft(stay) {
const owner = state.quickOwner === "preview" ? "preview" : "outer";
const parsed = parseCapture(state.draft, fallbackProject());
if (parsed.refuse) {
state.refuse = parsed.refuse;
return false;
}
const afterRefuse = refuseAfter(parsed.after);
if (afterRefuse) {
state.refuse = afterRefuse;
return false;
}
if (!parsed.title) {
state.refuse = "title needed";
return false;
Expand All @@ -3197,6 +3238,9 @@ import { parseCapture } from "./capture.js";
status: "open",
project: parsed.project,
thread: parsed.thread === undefined ? null : parsed.thread,
assignee: parsed.assignee === undefined ? null : parsed.assignee,
base: parsed.base === undefined ? null : parsed.base,
after: [...(parsed.after || [])],
createdAt: now,
updatedAt: now,
};
Expand Down Expand Up @@ -4348,7 +4392,7 @@ import { parseCapture } from "./capture.js";
);
const footer =
state.overlay === "quick" && !state.quickExpanded
? `<div class="tsk-input-row"><span class="tsk-prompt">+</span><input class="tsk-field" id="tsk-add" value="${esc(state.draft)}" placeholder="title · !p project · !t thread" autocomplete="off" /><span class="cursor">█</span></div>
? `<div class="tsk-input-row"><span class="tsk-prompt">+</span><input class="tsk-field" id="tsk-add" value="${esc(state.draft)}" placeholder="title… !p project · !t thread · !a assignee · !b base" autocomplete="off" /><span class="cursor">█</span></div>
<div class="foot dim tsk-verbs">${
state.refuse
? esc(state.refuse)
Expand Down
116 changes: 106 additions & 10 deletions site/public/capture.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
function normalizeThread(raw) {
const TOKEN = new Set(["!p", "!t", "!a", "!b", "!w"]);

function normalizeName(raw, kind) {
const input = String(raw);
if (!/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(input)) return null;
return input.toLowerCase();
if (!input) return { refuse: `${kind} name is empty` };
if (!/^[A-Za-z0-9]/.test(input)) {
return { refuse: `${kind} must start with a letter or number` };
}
if (!/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(input)) {
return { refuse: `${kind}: use letters, numbers, hyphens, and dots` };
}
if ([...input].length > 32) {
return { refuse: `${kind} is at most 32 characters` };
}
return { value: input.toLowerCase() };
}

function normalizeThread(raw) {
return normalizeName(raw, "thread");
}

function normalizeAssignee(raw) {
let name = String(raw).trim();
if (name.startsWith("@")) name = name.slice(1);
if (/^(me|you)$/i.test(name)) return { value: "you" };
return normalizeName(name, "assignee");
}

function tokenArgument(parts, index) {
const next = parts[index + 1];
if (!next || TOKEN.has(next) || next.startsWith("#")) return undefined;
return next;
}

function parseAfterNumber(value) {
const digits = value.replace(/^[Tt]/, "");
if (!digits || !/^\d+$/.test(digits)) return null;
const number = Number(digits);
return number > 0 ? number : null;
}

export function parseCapture(raw, fallbackProject) {
const parts = raw.trim().split(/\s+/).filter(Boolean);
const parts = String(raw).trim().split(/\s+/).filter(Boolean);
let project = fallbackProject;
let thread;
let assignee;
let base;
const after = [];
const title = [];
let refuse = "";

for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
if (part === "!p") {
const argument = parts[i + 1];
if (!argument || argument.startsWith("!")) {
const argument = tokenArgument(parts, i);
if (argument === undefined) {
project = null;
} else {
i += 1;
Expand All @@ -22,16 +62,72 @@ export function parseCapture(raw, fallbackProject) {
continue;
}
if (part === "!t") {
const argument = parts[i + 1];
if (!argument || argument.startsWith("!")) {
const argument = tokenArgument(parts, i);
if (argument === undefined) {
thread = null;
} else {
i += 1;
thread = normalizeThread(argument);
const normalized = normalizeThread(argument);
if (normalized.refuse) {
refuse = normalized.refuse;
thread = null;
break;
}
thread = normalized.value;
}
continue;
}
if (part === "!a") {
const argument = tokenArgument(parts, i);
if (argument === undefined) {
assignee = null;
} else {
i += 1;
const normalized = normalizeAssignee(argument);
if (normalized.refuse) {
refuse = normalized.refuse;
assignee = null;
break;
}
assignee = normalized.value;
}
continue;
}
if (part === "!b") {
const argument = tokenArgument(parts, i);
if (argument === undefined) {
base = null;
} else {
i += 1;
base = argument;
}
continue;
}
if (part === "!w") {
const argument = tokenArgument(parts, i);
const number = argument ? parseAfterNumber(argument) : null;
if (number === null) {
refuse = "!w needs a task number like T12";
break;
}
i += 1;
if (!after.includes(number)) after.push(number);
continue;
}
title.push(part);
}
return { title: title.join(" "), project, thread };

if (!refuse && base && !project) {
refuse = "base requires a project task";
}

return {
title: title.join(" "),
project,
thread,
assignee,
base,
after,
refuse,
};
}
2 changes: 1 addition & 1 deletion site/scripts/sync-agents-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

import { DEFINITION_SENTENCE, SKILL_PAGES, stripFrontmatter } from "../src/lib/agent-docs.mjs";
import { SKILL_PAGES, stripFrontmatter } from "../src/lib/agent-docs.mjs";

const siteRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const repoRoot = dirname(siteRoot);
Expand Down
8 changes: 2 additions & 6 deletions site/src/lib/agent-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ export const SIDEBAR_SLUGS = [
export const SOURCE_REPO_BASE = "https://github.com/smarzban/tsk/blob/main";
export const AGENTS_SKILL_REPO_PATH = "skills/tsk-cli/SKILL.md";
export const COORDINATOR_SKILL_REPO_PATH = "skills/tsk-coordinator/SKILL.md";
export function agentsSkillFsPath() {
return resolve(process.cwd(), "../skills/tsk-cli/SKILL.md");
}

// Docs pages generated from a skill (scripts/sync-agents-page.mjs), keyed by slug.
export const SKILL_PAGES = {
Expand Down Expand Up @@ -106,14 +103,13 @@ export function twinSource(slug, filePath) {
};
}

export function formatTwin({ title, slug, sourcePath, fileName, updatedIso, body }) {
export function formatTwin({ title, slug, sourcePath, updatedIso, body }) {
const trimmed = String(body).replace(/^\uFEFF/, "").replace(/\s+$/, "");
const path = sourcePath || `site/src/content/docs/docs/${fileName}`;
return [
`# ${title}`,
"",
`- html: ${htmlUrlForSlug(slug)}`,
`- source: ${SOURCE_REPO_BASE}/${path}`,
`- source: ${SOURCE_REPO_BASE}/${sourcePath}`,
`- updated: ${updatedIso}`,
"",
trimmed,
Expand Down
Loading
Loading