← Files note Workspace|ROGNALIAARCHIVED FILE
skills/note-workspace-setup/scripts/prepare_role_task_kickoff.py
7.75 KB · Oct 5, 2026 · 18:34 UTC
#!/usr/bin/env python3
"""Build one role-task kickoff challenge without creating or messaging a task."""
from __future__ import annotations
import argparse
import hashlib
import json
import secrets
import sys
from pathlib import Path
from typing import Optional
from task_binding_common import (
PRIMARY_ROLES_BY_MODE,
TaskBindingError,
atomic_write_json,
challenge_path,
exclusive_lock,
parse_timestamp,
resolve_workspace,
)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Prepare a host-readback kickoff prompt for one user-visible task."
)
parser.add_argument("workspace", help="Absolute path to the user workspace")
parser.add_argument(
"--role",
required=True,
choices=("strategy", "tracker", "writer", "image", "diary", "compact"),
)
parser.add_argument("--task-id", required=True, help="Task ID read back from the host")
parser.add_argument("--host-id", required=True, help="Host ID read back from the host")
parser.add_argument("--slot", default="primary", help="primary or approved extra image slot")
parser.add_argument(
"--binding-origin",
required=True,
choices=("reused_setup", "created"),
)
parser.add_argument("--timestamp", help="ISO 8601 challenge issue time override")
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = _parser().parse_args(argv)
try:
workspace, manifest, settings = resolve_workspace(args.workspace)
mode = settings["task_topology"]["mode"]
generation = settings["task_topology"]["binding_generation"]
if args.slot != "primary" and not (
args.role == "image" and args.slot.startswith("extra-")
):
raise TaskBindingError("only image may use an approved extra slot")
if args.slot == "primary":
if args.role not in PRIMARY_ROLES_BY_MODE[mode]:
raise TaskBindingError(f"{mode} requires a matching primary role")
if args.binding_origin == "reused_setup" and not (
args.slot == "primary" and args.role in {"strategy", "compact"}
):
raise TaskBindingError(
"only a primary strategy or compact task may reuse setup"
)
standing_path = workspace / "profile/standing-instructions.json"
if not standing_path.is_file() or standing_path.is_symlink():
raise TaskBindingError("standing instructions state is missing or unsafe")
standing = json.loads(standing_path.read_text(encoding="utf-8"))
revision = standing.get("revision")
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
raise TaskBindingError("standing instructions revision is invalid")
task_id = args.task_id.strip()
if not task_id or "\n" in task_id or "\r" in task_id:
raise TaskBindingError("task ID must be one-line text")
host_id = args.host_id.strip()
if not host_id or "\n" in host_id or "\r" in host_id:
raise TaskBindingError("host ID must be one-line text")
title = settings["task_names"][args.role]
issued_at, _ = parse_timestamp(args.timestamp)
nonce = "ready-" + secrets.token_hex(16)
expected = {
"nonce": nonce,
"workspace_id": manifest["workspace_id"],
"workspace_path": str(workspace),
"role": args.role,
"task_id": task_id,
"host_id": host_id,
"task_title": title,
"standing_instructions_revision": revision,
}
initial_paths = []
start_here_path = workspace / "START_HERE.md"
if start_here_path.is_symlink() or (
start_here_path.exists() and not start_here_path.is_file()
):
raise TaskBindingError("START_HERE.md is unsafe")
if start_here_path.is_file():
initial_paths.append(start_here_path)
elif manifest.get("generator_version") != "0.3.0-dev":
raise TaskBindingError("START_HERE.md is required for this workspace")
initial_paths.extend(
(
workspace / "STUDIO.md",
workspace / "strategy/operating-settings.json",
workspace / "profile/standing-instructions.md",
workspace / "strategy/role-task-plan.md",
)
)
initial_files = "、".join(str(path) for path in initial_paths)
kickoff = (
f"あなたは{title}の担当です。最初に{initial_files}を読み、"
"担当範囲と担当外を確認してください。"
"担当の処理にはnote Workspaceの同梱Skillを使います。"
"Plugin導入ではrognalia-note-workspaceがこのタスクで有効であり、"
"必要なSkillを参照できることを確認してください。"
"note Studio mini等の同名Skillへ切り替えないでください。"
"Skill単独導入の場合は同じ配布元のSkillを確認します。"
"必要なSkillを参照できない時はREADY_RECEIPTを返さず、"
"有効化に必要な次の一手だけを伝えてください。"
"会話だけを正本にせず、永続情報はworkspaceへ保存してください。"
"利用者へ、自分が何を支えるか、普段このタスクを開く必要があるか、"
"最初の話しかけ方の例を短く案内し、"
"最後の一行に、次のREADY_RECEIPTを値を変えず返してください: "
"READY_RECEIPT "
+ json.dumps(
expected,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
)
kickoff_sha256 = hashlib.sha256(kickoff.encode("utf-8")).hexdigest()
challenge = {
"schema_version": 1,
"status": "issued",
"ready_nonce": nonce,
"workspace_id": manifest["workspace_id"],
"task_mode": mode,
"binding_generation": generation,
"role": args.role,
"slot": args.slot,
"task_id": task_id,
"host_id": host_id,
"task_title": title,
"binding_origin": args.binding_origin,
"standing_instructions_revision": revision,
"issued_at": issued_at,
"kickoff_message_sha256": kickoff_sha256,
"host_evidence_sha256": None,
"challenge_proof_sha256": None,
"verified_at": None,
"consumed_at": None,
"consumed_event_id": None,
}
state_path = challenge_path(workspace, nonce)
with exclusive_lock(workspace / "strategy/.task-binding.lock", issued_at):
atomic_write_json(state_path, challenge, create=True)
print(
json.dumps(
{
"task_mode": mode,
"binding_generation": generation,
"binding_origin": args.binding_origin,
"issued_at": issued_at,
"kickoff_message_sha256": kickoff_sha256,
"kickoff": kickoff,
"expected_ready_receipt": expected,
"challenge_path": state_path.relative_to(workspace).as_posix(),
"external_actions": [],
},
ensure_ascii=False,
indent=2,
)
)
return 0
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(
json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False),
file=sys.stderr,
)
return 2
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: bf498ba2adad3d8559d0169767d7db7843b57a39353bb27766ac7bfd8eafa919