ticket: remove tickets shell shim

This commit is contained in:
Keisuke Hirata 2026-06-06 07:03:08 +09:00
parent 8c6264d699
commit a9c5c3a821
No known key found for this signature in database
13 changed files with 56 additions and 649 deletions

View File

@ -268,7 +268,7 @@ Validation:
- cargo fmt --check
- cargo check --workspace
- cargo test ...
- ./tickets.sh doctor
- yoi ticket doctor
Parent decision needed:
- none / specific question

View File

@ -62,7 +62,7 @@ Intake は以下を行う。
Intake は `TicketReview`, `TicketStatus`, `TicketClose` を通常使わない。review / status transition / close は Orchestrator または reviewer / maintainer workflow の責務である。
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `tickets.sh` を使う人間/親 workflow に戻す。
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
## 手順

View File

@ -1,5 +1,5 @@
---
description: ticket を実装委譲する前に、要件・前提・設計境界・反証観点を同期し、tickets.sh に記録する preflight フロー
description: ticket を実装委譲する前に、要件・前提・設計境界・反証観点を同期し、Ticket thread に記録する preflight フロー
model_invokation: true
user_invocable: true
requires: []
@ -23,12 +23,12 @@ yoi プロジェクトで ticket を実装に渡す前に、要件・前提・
小さなバグ修正や仕様が明確な局所変更では、この Workflow は省略してよい。ただし省略理由が曖昧な場合は preflight する。
## tickets.sh 運用方針
## Ticket 記録方針
作業管理の authority は `work-items/` と `tickets.sh` である。preflight の結果は、口頭の会話だけで終わらせず、ticket の `thread.md` または `item.md` に残す。
作業管理の authority は `.yoi/tickets/` に保存される Ticket と git history である。preflight の結果は、口頭の会話だけで終わらせず、Ticket tool または `yoi ticket ...`ticket の `thread.md` または `item.md` に残す。
- 新規の前提・要件・受け入れ条件は、必要に応じて `item.md` を更新する。
- 調査結果・実装前 plan は `./tickets.sh comment <ticket> --role plan --file <file>` で残す。
- 調査結果・実装前 plan は `TicketComment` または `yoi ticket comment <ticket> --role plan --file <file>` で残す。
- 採用/却下した設計判断、実装停止判断、仕様同期の結論は `--role decision` で残す。
- 実装に入ってよい状態になったら、その根拠を intent packet として ticket thread に残す。
- 仕様が未決定なら、実装 ticket にせず requirements-sync / spike / design ticket として切り分ける。

View File

@ -47,20 +47,20 @@ docs-only など Nix build の価値が低い変更で省略する場合は、
## Work item / Ticket の運用について
作業管理は `.yoi/tickets/` `tickets.sh` を正とする。時系列・状態遷移の最終的な根拠は git history なので、work item の作成・更新・レビュー・完了はファイル操作と commit で表現する。
作業管理は `.yoi/tickets/` に保存される Ticket と `yoi ticket ...` コマンド / Ticket tools を正とする。時系列・状態遷移の最終的な根拠は git history なので、work item の作成・更新・レビュー・完了は Ticket 操作と commit で表現する。
### 基本コマンド
- 新規作成: `./tickets.sh create --title "..." [--slug slug] [--kind task] [--priority P2] [--label a,b]`
- 一覧: `./tickets.sh list [--status open|pending|closed|all]`
- 詳細: `./tickets.sh show <id-or-slug>`
- コメント / 計画 / 判断 / 実装報告: `./tickets.sh comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]`
- レビュー記録: `./tickets.sh review <id-or-slug> --approve|--request-changes [--file path]`
- 状態変更: `./tickets.sh status <id-or-slug> open|pending|closed`
- 完了: `./tickets.sh close <id-or-slug> [--resolution text|--file path]`
- 整合性確認: `./tickets.sh doctor`
- 新規作成: `yoi ticket create --title "..." [--slug slug] [--kind task] [--priority P2] [--label a,b]`
- 一覧: `yoi ticket list [--status open|pending|closed|all]`
- 詳細: `yoi ticket show <id-or-slug>`
- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]`
- レビュー記録: `yoi ticket review <id-or-slug> --approve|--request-changes [--file path]`
- 状態変更: `yoi ticket status <id-or-slug> open|pending`
- 完了: `yoi ticket close <id-or-slug> [--resolution text|--file path]`
- 整合性確認: `yoi ticket doctor`
`tickets.sh` は `.yoi/tickets/{open,pending,closed}/<id>/` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。完了時は `resolution.md` も作られる。手でファイルを作るより、原則としてスクリプトを使うこと。
`yoi ticket` は typed Ticket backend 経由で `.yoi/tickets/{open,pending,closed}/<id>/` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。完了時は `resolution.md` も作られる。手でファイルを作るより、原則として `yoi ticket` または Ticket tools を使うこと。
### Work item の粒度
@ -71,10 +71,10 @@ docs-only など Nix build の価値が低い変更で省略する場合は、
### ライフサイクル
- 作成: `./tickets.sh create ...` で `.yoi/tickets/open/...` を作成し、必要な前提を書いて commit する。
- 詳細化・前提変更: `item.md` を更新し、必要に応じて `./tickets.sh comment` で `thread.md` に経緯を残して commit する。
- レビュー: `./tickets.sh review <id-or-slug> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
- 完了: `./tickets.sh close <id-or-slug>` で `.yoi/tickets/closed/...` に移動し、`resolution.md` と完了状態を commit する。
- 作成: `yoi ticket create ...` で `.yoi/tickets/open/...` を作成し、必要な前提を書いて commit する。
- 詳細化・前提変更: `item.md` を更新し、必要に応じて `yoi ticket comment` で `thread.md` に経緯を残して commit する。
- レビュー: `yoi ticket review <id-or-slug> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
- 完了: `yoi ticket close <id-or-slug>` で `.yoi/tickets/closed/...` に移動し、`resolution.md` と完了状態を commit する。
worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。

View File

@ -68,12 +68,12 @@ Key docs:
## 5. Development
This repository dogfoods Yoi to develop Yoi. Work is tracked through `.yoi/tickets/` and `./tickets.sh`; git history plus Ticket files are the authoritative project record.
This repository dogfoods Yoi to develop Yoi. Work is tracked through `.yoi/tickets/` and `yoi ticket ...`; git history plus Ticket files are the authoritative project record.
Common checks:
```sh
./tickets.sh doctor
yoi ticket doctor
git diff --check
cargo fmt --check
cargo check --workspace --all-targets

View File

@ -15,8 +15,8 @@ Does not own:
- memory-specific policy (`memory`)
- workflow-specific policy (`workflow`)
- Ticket backend policy (`ticket`)
- product CLI command shape (`yoi`)
- work item script behavior (`tickets.sh`)
## Design notes

View File

@ -2,7 +2,7 @@
//!
//! The public domain name is **Ticket**. `LocalTicketBackend` preserves the
//! repository's current `.yoi/tickets/{open,pending,closed}/<id>/` layout and the
//! event format used by `tickets.sh` while exposing typed Rust operations.
//! event/thread format while exposing typed Rust operations.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
@ -1330,9 +1330,9 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
}
fn parse_event_comment(comment: &str) -> BTreeMap<String, String> {
// tickets.sh emits unquoted `key: value` pairs separated by spaces. Values
// currently do not contain spaces; this parser intentionally preserves the
// compatibility shape instead of treating thread.md as strict YAML.
// Thread event comments use unquoted `key: value` pairs separated by spaces.
// Values currently do not contain spaces; this parser intentionally preserves
// the local file format instead of treating thread.md as strict YAML.
let mut attrs = BTreeMap::new();
let mut iter = comment.split_whitespace().peekable();
while let Some(token) = iter.next() {
@ -1528,31 +1528,6 @@ mod tests {
LocalTicketBackend::new(dir.path().join("tickets"))
}
fn script_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..//tickets.sh")
}
fn run_tickets_sh(work_items: &Path, args: &[&str]) -> std::process::Output {
std::process::Command::new(script_path())
.current_dir(work_items.parent().unwrap())
.env("WORK_ITEMS_DIR", work_items)
.args(args)
.output()
.unwrap()
}
fn assert_script_ok(work_items: &Path, args: &[&str]) -> String {
let output = run_tickets_sh(work_items, args);
assert!(
output.status.success(),
"tickets.sh {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn parses_item_frontmatter_and_optional_fields() {
let item = r#"---
@ -1586,7 +1561,7 @@ action_required: none
}
#[test]
fn create_writes_tickets_sh_compatible_layout() {
fn create_writes_local_ticket_layout() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut input = NewTicket::new("Example Ticket");
@ -1597,11 +1572,12 @@ action_required: none
assert!(dir.join("thread.md").exists());
assert!(dir.join("artifacts/.gitkeep").exists());
assert_eq!(ticket.slug, "example-ticket");
assert_script_ok(&tmp.path().join("tickets"), &["doctor"]);
let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics);
}
#[test]
fn add_event_review_status_and_close_are_script_compatible() {
fn add_event_review_status_and_close_preserve_local_layout() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
@ -1638,39 +1614,8 @@ action_required: none
assert!(thread.contains("<!-- event: review"));
assert!(thread.contains("status: approve"));
assert!(thread.contains("<!-- event: close"));
assert_script_ok(&tmp.path().join("tickets"), &["doctor"]);
}
#[test]
fn reads_ticket_created_by_tickets_sh_and_script_mutates_rust_ticket() {
let tmp = TempDir::new().unwrap();
let work_items = tmp.path().join("tickets");
let id = assert_script_ok(
&work_items,
&[
"create",
"--title",
"Shell Created",
"--slug",
"shell-created",
],
);
let id = id.trim();
let backend = LocalTicketBackend::new(&work_items);
let ticket = backend.show(TicketIdOrSlug::Id(id.to_string())).unwrap();
assert_eq!(ticket.meta.slug, "shell-created");
let rust_ticket = backend.create(NewTicket::new("Rust Created")).unwrap();
assert_script_ok(
&work_items,
&["comment", &rust_ticket.slug, "--author", "test"],
);
let shown = backend.show(TicketIdOrSlug::Id(rust_ticket.id)).unwrap();
assert!(
shown
.events
.iter()
.any(|event| event.kind == TicketEventKind::Comment)
);
let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics);
}
#[test]

View File

@ -42,7 +42,7 @@ pub const TICKET_TOOL_NAMES: [&str; 8] = [
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
backend assigns the id and writes tickets.sh-compatible files under the configured backend root.";
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend. Filter by \
status (`open`, `pending`, `closed`, or `all`) and optionally kind/priority/label. Output is a \
bounded JSON summary list, not full ticket bodies.";
@ -56,12 +56,12 @@ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `tickets.sh doctor`.";
by `yoi ticket doctor`.";
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \
a close event.";
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics. This does not shell out to tickets.sh.";
diagnostics through the typed backend without shelling out to external commands.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCreateParams {

View File

@ -15,7 +15,7 @@ Owns:
Does not own:
- generated memory records (`memory`)
- Ticket file lifecycle (`tickets.sh`, `.yoi/tickets/`)
- Ticket file lifecycle (`crates/ticket`, `.yoi/tickets/`)
- Pod orchestration decisions (`pod`, workflows executed by agents)
- product CLI command shape (`yoi`)

View File

@ -7,7 +7,7 @@ Validation should match the change. Do not run expensive broad checks just to lo
Minimum checks:
```sh
./tickets.sh doctor
yoi ticket doctor
git diff --check
```
@ -37,7 +37,7 @@ Avoid repository-wide formatting churn when a validation failure is caused by pr
Run:
```sh
./tickets.sh doctor
yoi ticket doctor
```
Use work item review to verify that the implementation satisfies the ticket, not only that the diff looks plausible.

View File

@ -24,7 +24,7 @@ Use the highest-level interface that matches the work:
- Inside Pods, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
- For multi-step work, follow the Ticket Intake, Orchestrator Routing, Preflight, and Multi-agent workflows.
The local `.yoi/tickets/` files and `tickets.sh` compatibility CLI are backend/maintenance surfaces. They are documented later for maintainers, tests, and compatibility work, but normal user instructions should not start there.
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through TUI role actions, Ticket tools, or `yoi ticket ...`.
## Ticket tools inside Pods
@ -200,7 +200,7 @@ Implementation normally happens in a child git worktree created by the Orchestra
Reviewer Pods should be sibling Pods, not children of coder Pods. They should read the Ticket, intent packet, diff, implementation report, and validation evidence.
Review results should be recorded with the `TicketReview` tool. Maintainers working directly with the local backend can use the compatibility CLI documented later.
Review results should be recorded with the `TicketReview` tool. Maintainers working directly with the local backend can use the `yoi ticket` CLI documented later.
Blockers must be fixed or explicitly escalated before merge-ready submission.
@ -372,21 +372,23 @@ Keep long research dumps out of the item body. Put necessary artifacts under the
Do not store secrets, credentials, private prompt contents, or raw logs containing secrets in Ticket bodies, thread entries, artifacts, diagnostics, or model-visible prompts.
## Backend/maintainer compatibility: local Ticket CLI
## Backend/maintainer CLI: `yoi ticket`
`./tickets.sh` is the local-file compatibility and maintainer CLI for the current `.yoi/tickets/` backend. It is useful for repository maintenance, tests, migration/debugging, and low-level recovery, but it is not the primary user-facing path.
The product CLI exposes the typed Ticket backend for repository maintenance and validation. It operates on the configured `.yoi/tickets/` storage and is the preferred command-line surface when editing Tickets outside a Pod.
```sh
./tickets.sh create --title "..." [--slug slug] [--kind task] [--priority P2] [--label a,b]
./tickets.sh list [--status open|pending|closed|all]
./tickets.sh show <id-or-slug>
./tickets.sh comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]
./tickets.sh review <id-or-slug> --approve|--request-changes [--file path]
./tickets.sh status <id-or-slug> open|pending|closed
./tickets.sh close <id-or-slug> [--resolution text|--file path]
./tickets.sh doctor
yoi ticket create --title "..." [--slug slug] [--kind task] [--priority P2] [--label a,b]
yoi ticket list [--status open|pending|closed|all]
yoi ticket show <id-or-slug>
yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]
yoi ticket review <id-or-slug> --approve|--request-changes [--file path]
yoi ticket status <id-or-slug> open|pending
yoi ticket close <id-or-slug> [--resolution text|--file path]
yoi ticket doctor
```
`yoi ticket status` intentionally does not close Tickets. Closing must use `yoi ticket close` so the backend writes the required `resolution.md` and passes `yoi ticket doctor`.
The current LocalTicketBackend stores records under:
```text
@ -397,14 +399,14 @@ The current LocalTicketBackend stores records under:
resolution.md # closed Tickets only
```
Backend integrations must preserve this format until an explicit migration changes it. The repository-root `work-items/` directory is a legacy notice only, not a live mutable backend. Human users should prefer TUI role actions or Ticket tools; maintainers may use `tickets.sh` when working directly with repository records.
Backend integrations must preserve this format until an explicit migration changes it. The repository-root `work-items/` directory is a legacy notice only, not a live mutable backend. Human users should prefer TUI role actions or Ticket tools; maintainers may use `yoi ticket ...` when working directly with repository records.
## Validation
Run at least:
```sh
./tickets.sh doctor
yoi ticket doctor
git diff --check
```

View File

@ -1,540 +0,0 @@
#!/bin/sh
set -eu
WORK_ITEMS_DIR=${WORK_ITEMS_DIR:-.yoi/tickets}
STATUSES="open pending closed"
REQUIRED_FIELDS="id slug title status kind priority labels created_at updated_at assignee legacy_ticket"
usage() {
cat <<'EOF'
tickets.sh - transitional repository-local Ticket helper
Usage:
./tickets.sh help
./tickets.sh --help
./tickets.sh list [--status open|pending|closed|all]
./tickets.sh show <id-or-slug>
./tickets.sh create --title <title> [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]
./tickets.sh comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--author <name>] [--file <path>]
./tickets.sh review <id-or-slug> --approve|--request-changes [--author <name>] [--file <path>]
./tickets.sh status <id-or-slug> open|pending|closed
./tickets.sh close <id-or-slug> [--resolution <text>|--file <path>]
./tickets.sh doctor
Backend:
.yoi/tickets/{open,pending,closed}/<id>/item.md
.yoi/tickets/{open,pending,closed}/<id>/thread.md
.yoi/tickets/{open,pending,closed}/<id>/artifacts/
Transition policy:
.yoi/tickets/ is the active built-in local Ticket backend. This script remains
a maintainer shim until yoi ticket fully replaces it; WORK_ITEMS_DIR may be
set for one-off legacy/recovery checks, but do not maintain a second live root.
Review notes must be appended to thread.md instead of tickets/*.review.md.
EOF
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
now_utc() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
compact_date() {
date -u '+%Y%m%d-%H%M%S'
}
ensure_backend_dirs() {
mkdir -p "$WORK_ITEMS_DIR/open" "$WORK_ITEMS_DIR/pending" "$WORK_ITEMS_DIR/closed"
}
is_status() {
case "$1" in
open|pending|closed) return 0 ;;
*) return 1 ;;
esac
}
slugify() {
# ASCII-focused slugifier for POSIX shell. Non-ASCII titles should pass --slug.
printf '%s' "$1" |
tr '[:upper:]' '[:lower:]' |
sed 's/[^a-z0-9][^a-z0-9]*/-/g; s/^-//; s/-$//; s/--*/-/g'
}
field_value() {
file=$1
field=$2
awk -v key="$field" '
NR == 1 && $0 == "---" { in_fm = 1; next }
in_fm && $0 == "---" { exit }
in_fm {
prefix = key ":"
if (index($0, prefix) == 1) {
value = substr($0, length(prefix) + 1)
sub(/^[[:space:]]*/, "", value)
print value
exit
}
}
' "$file"
}
set_frontmatter_field() {
file=$1
field=$2
value=$3
tmp=${TMPDIR:-/tmp}/tickets-sh.$$.tmp
awk -v key="$field" -v new_value="$value" '
NR == 1 && $0 == "---" { in_fm = 1; print; next }
in_fm && $0 == "---" { in_fm = 0; print; next }
in_fm {
prefix = key ":"
if (index($0, prefix) == 1) {
print key ": " new_value
next
}
}
{ print }
' "$file" > "$tmp"
mv "$tmp" "$file"
}
find_item_dir() {
query=$1
matches=${TMPDIR:-/tmp}/tickets-sh.matches.$$
: > "$matches"
for status in $STATUSES; do
for dir in "$WORK_ITEMS_DIR/$status"/*; do
[ -d "$dir" ] || continue
item=$dir/item.md
[ -f "$item" ] || continue
id=$(field_value "$item" id || true)
slug=$(field_value "$item" slug || true)
if [ "$query" = "$id" ] || [ "$query" = "$slug" ]; then
printf '%s\n' "$dir" >> "$matches"
fi
done
done
count=$(wc -l < "$matches" | tr -d ' ')
if [ "$count" -eq 0 ]; then
rm -f "$matches"
die "work item not found: $query"
fi
if [ "$count" -gt 1 ]; then
cat "$matches" >&2
rm -f "$matches"
die "ambiguous work item: $query"
fi
sed -n '1p' "$matches"
rm -f "$matches"
}
item_status_from_dir() {
case "$1" in
*/open/*) printf 'open\n' ;;
*/pending/*) printf 'pending\n' ;;
*/closed/*) printf 'closed\n' ;;
*) printf 'unknown\n' ;;
esac
}
labels_yaml() {
labels=$1
if [ -z "$labels" ]; then
printf '[]'
return
fi
old_ifs=$IFS
IFS=,
first=1
printf '['
for label in $labels; do
clean=$(printf '%s' "$label" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
[ -n "$clean" ] || continue
if [ "$first" -eq 0 ]; then
printf ', '
fi
printf '%s' "$clean"
first=0
done
IFS=$old_ifs
printf ']'
}
read_body_to_file() {
input_file=$1
output_file=$2
if [ -n "$input_file" ]; then
[ -f "$input_file" ] || die "file not found: $input_file"
cat "$input_file" > "$output_file"
return
fi
if [ -t 0 ]; then
printf 'No body provided.\n' > "$output_file"
else
cat > "$output_file"
fi
}
append_thread_event() {
dir=$1
event=$2
heading=$3
author=$4
status_value=$5
body_file=$6
at=$(now_utc)
thread=$dir/thread.md
[ -f "$thread" ] || : > "$thread"
{
printf '\n<!-- event: %s author: %s at: %s' "$event" "$author" "$at"
if [ -n "$status_value" ]; then
printf ' status: %s' "$status_value"
fi
printf ' -->\n\n'
printf '## %s\n\n' "$heading"
cat "$body_file"
printf '\n\n---\n'
} >> "$thread"
set_frontmatter_field "$dir/item.md" updated_at "$at"
}
cmd_list() {
status_filter=open
while [ "$#" -gt 0 ]; do
case "$1" in
--status)
[ "$#" -ge 2 ] || die "--status requires a value"
status_filter=$2
shift 2
;;
*) die "unknown list argument: $1" ;;
esac
done
case "$status_filter" in
open|pending|closed|all) ;;
*) die "invalid status: $status_filter" ;;
esac
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' status id slug title kind priority updated_at
for status in $STATUSES; do
if [ "$status_filter" != all ] && [ "$status_filter" != "$status" ]; then
continue
fi
for dir in "$WORK_ITEMS_DIR/$status"/*; do
[ -d "$dir" ] || continue
item=$dir/item.md
[ -f "$item" ] || continue
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(field_value "$item" status)" \
"$(field_value "$item" id)" \
"$(field_value "$item" slug)" \
"$(field_value "$item" title)" \
"$(field_value "$item" kind)" \
"$(field_value "$item" priority)" \
"$(field_value "$item" updated_at)"
done
done
}
cmd_show() {
[ "$#" -eq 1 ] || die "show requires <id-or-slug>"
dir=$(find_item_dir "$1")
item=$dir/item.md
thread=$dir/thread.md
printf '# %s\n\n' "$(field_value "$item" title)"
printf 'Path: %s\n' "$dir"
printf 'Status: %s\n' "$(field_value "$item" status)"
printf 'ID: %s\n' "$(field_value "$item" id)"
printf 'Slug: %s\n\n' "$(field_value "$item" slug)"
printf '## item.md\n\n'
cat "$item"
printf '\n\n## thread.md\n\n'
if [ -f "$thread" ]; then
tail -n 80 "$thread"
else
printf '(missing thread.md)\n'
fi
}
cmd_create() {
title=
slug=
kind=task
priority=P2
labels=
while [ "$#" -gt 0 ]; do
case "$1" in
--title) [ "$#" -ge 2 ] || die "--title requires a value"; title=$2; shift 2 ;;
--slug) [ "$#" -ge 2 ] || die "--slug requires a value"; slug=$2; shift 2 ;;
--kind) [ "$#" -ge 2 ] || die "--kind requires a value"; kind=$2; shift 2 ;;
--priority) [ "$#" -ge 2 ] || die "--priority requires a value"; priority=$2; shift 2 ;;
--label) [ "$#" -ge 2 ] || die "--label requires a value"; labels=$2; shift 2 ;;
*) die "unknown create argument: $1" ;;
esac
done
[ -n "$title" ] || die "create requires --title"
if [ -z "$slug" ]; then
slug=$(slugify "$title")
else
slug=$(slugify "$slug")
fi
[ -n "$slug" ] || slug=item
ensure_backend_dirs
stamp=$(compact_date)
id=$stamp-$slug
dir=$WORK_ITEMS_DIR/open/$id
if [ -e "$dir" ]; then
id=$id-$$
dir=$WORK_ITEMS_DIR/open/$id
fi
created=$(now_utc)
mkdir -p "$dir/artifacts"
: > "$dir/artifacts/.gitkeep"
cat > "$dir/item.md" <<EOF
---
id: $id
slug: $slug
title: $title
status: open
kind: $kind
priority: $priority
labels: $(labels_yaml "$labels")
created_at: $created
updated_at: $created
assignee: null
legacy_ticket: null
---
## Background
Created by tickets.sh.
## Acceptance criteria
- TBD
EOF
cat > "$dir/thread.md" <<EOF
<!-- event: create author: tickets.sh at: $created -->
## Created
Created by tickets.sh create.
---
EOF
printf '%s\n' "$id"
}
cmd_comment() {
[ "$#" -ge 1 ] || die "comment requires <id-or-slug>"
query=$1
shift
role=comment
author=${USER:-unknown}
file=
while [ "$#" -gt 0 ]; do
case "$1" in
--role) [ "$#" -ge 2 ] || die "--role requires a value"; role=$2; shift 2 ;;
--author) [ "$#" -ge 2 ] || die "--author requires a value"; author=$2; shift 2 ;;
--file) [ "$#" -ge 2 ] || die "--file requires a value"; file=$2; shift 2 ;;
*) die "unknown comment argument: $1" ;;
esac
done
dir=$(find_item_dir "$query")
body=${TMPDIR:-/tmp}/tickets-sh.body.$$
read_body_to_file "$file" "$body"
heading=$role
case "$role" in
comment) heading="Comment" ;;
plan) heading="Plan" ;;
decision) heading="Decision" ;;
implementation_report) heading="Implementation report" ;;
esac
append_thread_event "$dir" "$role" "$heading" "$author" "" "$body"
rm -f "$body"
}
cmd_review() {
[ "$#" -ge 1 ] || die "review requires <id-or-slug>"
query=$1
shift
author=${USER:-unknown}
file=
review_status=
while [ "$#" -gt 0 ]; do
case "$1" in
--approve) review_status=approve; shift ;;
--request-changes) review_status=request_changes; shift ;;
--author) [ "$#" -ge 2 ] || die "--author requires a value"; author=$2; shift 2 ;;
--file) [ "$#" -ge 2 ] || die "--file requires a value"; file=$2; shift 2 ;;
*) die "unknown review argument: $1" ;;
esac
done
[ -n "$review_status" ] || die "review requires --approve or --request-changes"
dir=$(find_item_dir "$query")
body=${TMPDIR:-/tmp}/tickets-sh.body.$$
read_body_to_file "$file" "$body"
if [ "$review_status" = approve ]; then
heading="Review: approve"
else
heading="Review: request changes"
fi
append_thread_event "$dir" review "$heading" "$author" "$review_status" "$body"
rm -f "$body"
}
cmd_status() {
[ "$#" -eq 2 ] || die "status requires <id-or-slug> <open|pending|closed>"
query=$1
new_status=$2
is_status "$new_status" || die "invalid status: $new_status"
dir=$(find_item_dir "$query")
id=$(field_value "$dir/item.md" id)
new_dir=$WORK_ITEMS_DIR/$new_status/$id
ensure_backend_dirs
if [ "$dir" != "$new_dir" ]; then
[ ! -e "$new_dir" ] || die "target already exists: $new_dir"
mv "$dir" "$new_dir"
dir=$new_dir
fi
set_frontmatter_field "$dir/item.md" status "$new_status"
set_frontmatter_field "$dir/item.md" updated_at "$(now_utc)"
}
cmd_close() {
[ "$#" -ge 1 ] || die "close requires <id-or-slug>"
query=$1
shift
resolution=
close_file=
while [ "$#" -gt 0 ]; do
case "$1" in
--resolution) [ "$#" -ge 2 ] || die "--resolution requires a value"; resolution=$2; shift 2 ;;
--file) [ "$#" -ge 2 ] || die "--file requires a value"; close_file=$2; shift 2 ;;
*) die "unknown close argument: $1" ;;
esac
done
cmd_status "$query" closed
dir=$(find_item_dir "$query")
body=${TMPDIR:-/tmp}/tickets-sh.body.$$
if [ -n "$close_file" ]; then
read_body_to_file "$close_file" "$body"
elif [ -n "$resolution" ]; then
printf '%s\n' "$resolution" > "$body"
else
printf 'Closed.\n' > "$body"
fi
cp "$body" "$dir/resolution.md"
append_thread_event "$dir" close "Closed" "${USER:-unknown}" closed "$body"
rm -f "$body"
}
doctor_error() {
printf 'doctor: %s\n' "$*" >&2
DOCTOR_ERRORS=$((DOCTOR_ERRORS + 1))
}
cmd_doctor() {
DOCTOR_ERRORS=0
for status in $STATUSES; do
[ -d "$WORK_ITEMS_DIR/$status" ] || doctor_error "missing directory: $WORK_ITEMS_DIR/$status"
done
ids=${TMPDIR:-/tmp}/tickets-sh.ids.$$
slugs=${TMPDIR:-/tmp}/tickets-sh.slugs.$$
: > "$ids"
: > "$slugs"
for status in $STATUSES; do
[ -d "$WORK_ITEMS_DIR/$status" ] || continue
for dir in "$WORK_ITEMS_DIR/$status"/*; do
[ -d "$dir" ] || continue
item=$dir/item.md
thread=$dir/thread.md
artifacts=$dir/artifacts
[ -f "$item" ] || { doctor_error "missing item.md: $dir"; continue; }
[ -f "$thread" ] || doctor_error "missing thread.md: $dir"
[ -d "$artifacts" ] || doctor_error "missing artifacts/: $dir"
first=$(sed -n '1p' "$item")
[ "$first" = "---" ] || doctor_error "item.md missing frontmatter opener: $item"
for field in $REQUIRED_FIELDS; do
value=$(field_value "$item" "$field" || true)
[ -n "$value" ] || doctor_error "missing required field '$field': $item"
done
id=$(field_value "$item" id || true)
slug=$(field_value "$item" slug || true)
fm_status=$(field_value "$item" status || true)
if [ -n "$id" ]; then
printf '%s\t%s\n' "$id" "$item" >> "$ids"
base=$(basename "$dir")
[ "$base" = "$id" ] || doctor_error "directory id mismatch: $dir has id $id"
fi
if [ -n "$slug" ]; then
printf '%s\t%s\n' "$slug" "$item" >> "$slugs"
fi
if [ "$fm_status" != "$status" ]; then
doctor_error "status mismatch: $item has '$fm_status' under '$status'"
fi
done
done
dup_ids=$(cut -f1 "$ids" | sort | uniq -d)
if [ -n "$dup_ids" ]; then
old_ifs=$IFS
IFS='
'
for dup in $dup_ids; do
[ -n "$dup" ] && doctor_error "duplicate id: $dup"
done
IFS=$old_ifs
fi
dup_slugs=$(cut -f1 "$slugs" | sort | uniq -d)
if [ -n "$dup_slugs" ]; then
old_ifs=$IFS
IFS='
'
for dup in $dup_slugs; do
[ -n "$dup" ] && doctor_error "duplicate slug: $dup"
done
IFS=$old_ifs
fi
rm -f "$ids" "$slugs"
if [ -f TODO.md ] && grep -Eq 'tickets/[^][ )]+\.md|tickets/.*\.review\.md' TODO.md; then
doctor_error "TODO.md still references legacy tickets/*.md"
fi
for f in tickets/*.md tickets/*.review.md; do
[ -e "$f" ] || continue
doctor_error "legacy ticket file remains: $f"
done
if [ "$DOCTOR_ERRORS" -eq 0 ]; then
printf 'doctor: ok\n'
return 0
fi
printf 'doctor: %s error(s)\n' "$DOCTOR_ERRORS" >&2
return 1
}
main() {
cmd=${1:-help}
case "$cmd" in
help|--help|-h) usage ;;
list) shift; cmd_list "$@" ;;
show) shift; cmd_show "$@" ;;
create) shift; cmd_create "$@" ;;
comment) shift; cmd_comment "$@" ;;
review) shift; cmd_review "$@" ;;
status) shift; cmd_status "$@" ;;
close) shift; cmd_close "$@" ;;
doctor) shift; [ "$#" -eq 0 ] || die "doctor takes no arguments"; cmd_doctor ;;
*) usage >&2; die "unknown command: $cmd" ;;
esac
}
main "$@"

View File

@ -4,4 +4,4 @@ Active Yoi Ticket storage has moved to `.yoi/tickets/`.
This directory is intentionally not a live mutable backend. It remains only as a compatibility notice for older references and migration history. Do not create `open/`, `pending/`, or `closed/` Ticket records here.
Use `yoi ticket ...`, Ticket tools, or the transitional `./tickets.sh` shim from the repository root; all default to `.yoi/tickets/`.
Use `yoi ticket ...` or Ticket tools; both operate on the active `.yoi/tickets/` storage.