dotmd-cli 0.88.1 → 0.89.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -3
- package/assets/opencode/plugin.js +29 -14
- package/bin/dotmd.mjs +10 -3
- package/package.json +2 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +21 -0
- package/runlist.config.example.mjs +5 -0
- package/src/atomic-mutation.mjs +2 -2
- package/src/codex-integration.mjs +81 -0
- package/src/config.mjs +9 -6
- package/src/export.mjs +1 -1
- package/src/guard.mjs +60 -7
- package/src/hub-membership.mjs +1 -1
- package/src/index-file.mjs +20 -5
- package/src/init.mjs +6 -6
- package/src/install.mjs +50 -2
- package/src/migrate-prompts.mjs +2 -2
- package/src/new.mjs +1 -1
- package/src/update.mjs +12 -1
package/README.md
CHANGED
|
@@ -37,13 +37,28 @@ integration for whichever host you run:
|
|
|
37
37
|
```bash
|
|
38
38
|
runlist install # what's installed for each host
|
|
39
39
|
runlist install claude # Claude Code plugin (marketplace + plugin)
|
|
40
|
+
runlist install codex # Codex skill and hooks (personal marketplace)
|
|
40
41
|
runlist install opencode # OpenCode plugin (one auto-discovered file)
|
|
41
42
|
runlist doctor --session # what identity runlist sees here, and from where
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
The integrations are one-time and global. Codex exports `CODEX_THREAD_ID` to
|
|
46
|
+
every tool shell, so its identity works without the plugin; the plugin adds a
|
|
47
|
+
workflow skill, session orientation, and the tool guard.
|
|
48
|
+
|
|
49
|
+
### Codex Plugin
|
|
50
|
+
|
|
51
|
+
`runlist install codex` copies the plugin included in the npm package into the
|
|
52
|
+
personal Codex marketplace and runs `codex plugin add runlist-codex@personal`.
|
|
53
|
+
Codex asks you to review and trust plugin hooks separately. Start a new thread
|
|
54
|
+
after installation so the skill and SessionStart primer load. The hooks use
|
|
55
|
+
the globally installed `runlist` CLI; a missing CLI produces one install hint
|
|
56
|
+
and otherwise leaves tool calls alone. `CODEX_THREAD_ID` stays the ownership
|
|
57
|
+
source. The CLI refuses to overwrite a plugin directory it did not generate.
|
|
58
|
+
|
|
59
|
+
Direct prompt reads warn by default in Codex and Claude. A repository can set
|
|
60
|
+
`export const guard = { promptReads: 'deny' };` to block reads of existing
|
|
61
|
+
pending prompts; `runlist prompts show` remains the read-only inspection verb.
|
|
47
62
|
|
|
48
63
|
### Claude Code Plugin
|
|
49
64
|
|
|
@@ -72,6 +87,9 @@ supplies the two things the CLI cannot get on its own:
|
|
|
72
87
|
- **A session-start briefing**, the equivalent of Claude Code's SessionStart
|
|
73
88
|
hook. OpenCode's Claude Code compatibility covers skills and the system
|
|
74
89
|
prompt, not hooks, so nothing else runs `runlist hud`.
|
|
90
|
+
- **Prompt-read guardrails.** Recognized reads are checked before execution so
|
|
91
|
+
the opt-in strict policy can stop them. Default warnings are attached to the
|
|
92
|
+
result because OpenCode's before hook cannot return model context.
|
|
75
93
|
|
|
76
94
|
Restart OpenCode after installing. The file is version-stamped (`runlist-generated:`, or
|
|
77
95
|
`dotmd-generated:` from older releases); a `dotmd.js` without either stamp is
|
|
@@ -11,10 +11,8 @@
|
|
|
11
11
|
// that isn't one — so an exported helper wouldn't just be untidy, it would
|
|
12
12
|
// be *called* as a second plugin. Helpers stay module-local.
|
|
13
13
|
//
|
|
14
|
-
// 2.
|
|
15
|
-
//
|
|
16
|
-
// body is wrapped, and a failure degrades to "runlist does nothing here"
|
|
17
|
-
// rather than to a broken session.
|
|
14
|
+
// 2. Only a deliberate guard denial may throw. OpenCode awaits hooks inside
|
|
15
|
+
// the chat request, so every other failure degrades to a no-op.
|
|
18
16
|
//
|
|
19
17
|
// Runs under Bun inside the OpenCode process. Node builtins only, no deps.
|
|
20
18
|
|
|
@@ -64,12 +62,9 @@ function runCli(directory, args, input = null) {
|
|
|
64
62
|
});
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
// OpenCode runs no Claude Code hooks
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// warning, not a block, so it is applied after the call: the teaching text is
|
|
71
|
-
// appended to what the agent sees. Only calls that name a prompt file reach
|
|
72
|
-
// the CLI; everything else costs a regex.
|
|
65
|
+
// OpenCode runs no Claude Code hooks. Evaluate recognized prompt reads before
|
|
66
|
+
// the tool so opt-in strict mode can block them. Its V1 before hook cannot
|
|
67
|
+
// return model context, so ordinary warnings are appended to the result.
|
|
73
68
|
const PROMPT_FILE = /(^|[\\/])prompts[\\/]\S*\.md\b/;
|
|
74
69
|
|
|
75
70
|
function guardPayload(tool, args) {
|
|
@@ -86,6 +81,14 @@ export default async function dotmdOpencodePlugin({ directory }) {
|
|
|
86
81
|
// Keyed by session so a subagent session primes independently, the way
|
|
87
82
|
// SubagentStart does under Claude Code.
|
|
88
83
|
const primers = new Map();
|
|
84
|
+
const guardDecisions = new Map();
|
|
85
|
+
|
|
86
|
+
async function guardFor(tool, args) {
|
|
87
|
+
const payload = guardPayload(tool, args);
|
|
88
|
+
if (!payload) return null;
|
|
89
|
+
const raw = await runCli(directory, ['guard'], JSON.stringify(payload));
|
|
90
|
+
return raw ? JSON.parse(raw)?.hookSpecificOutput ?? null : null;
|
|
91
|
+
}
|
|
89
92
|
|
|
90
93
|
async function primerFor(sessionId) {
|
|
91
94
|
const key = sessionId ?? '';
|
|
@@ -101,6 +104,18 @@ export default async function dotmdOpencodePlugin({ directory }) {
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
return {
|
|
107
|
+
'tool.execute.before': async (input, output) => {
|
|
108
|
+
let refusal = null;
|
|
109
|
+
try {
|
|
110
|
+
const decision = await guardFor(input?.tool, output?.args);
|
|
111
|
+
if (input?.callID && decision) guardDecisions.set(input.callID, decision);
|
|
112
|
+
if (decision?.permissionDecision === 'deny') {
|
|
113
|
+
if (input?.callID) guardDecisions.delete(input.callID);
|
|
114
|
+
refusal = decision.permissionDecisionReason;
|
|
115
|
+
}
|
|
116
|
+
} catch { /* a missing/broken guard must not fail the chat turn */ }
|
|
117
|
+
if (refusal) throw new Error(`[runlist] ${refusal}`);
|
|
118
|
+
},
|
|
104
119
|
// Ownership identity. OpenCode sets no session-id variable of its own, and
|
|
105
120
|
// `OPENCODE_PID` — what runlist falls back to without this plugin — names the
|
|
106
121
|
// OpenCode *process*, so every session in one TUI shares it and can release
|
|
@@ -126,10 +141,10 @@ export default async function dotmdOpencodePlugin({ directory }) {
|
|
|
126
141
|
// nothing left to refuse.
|
|
127
142
|
'tool.execute.after': async (input, output) => {
|
|
128
143
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const note =
|
|
144
|
+
if (typeof output?.output !== 'string') return;
|
|
145
|
+
const decision = input?.callID ? guardDecisions.get(input.callID) : null;
|
|
146
|
+
if (input?.callID) guardDecisions.delete(input.callID);
|
|
147
|
+
const note = (decision ?? await guardFor(input?.tool, input?.args))?.additionalContext;
|
|
133
148
|
if (typeof note === 'string' && note) output.output += `\n\n${note}`;
|
|
134
149
|
} catch { /* a guard failure never touches the tool result */ }
|
|
135
150
|
},
|
package/bin/dotmd.mjs
CHANGED
|
@@ -147,8 +147,9 @@ with a one-line recap naming the habit to break.`,
|
|
|
147
147
|
|
|
148
148
|
runlist install report what is installed for each known host
|
|
149
149
|
runlist install claude install the Claude Code plugin (marketplace + plugin)
|
|
150
|
+
runlist install codex install/refresh the Codex plugin (personal marketplace)
|
|
150
151
|
runlist install opencode install/refresh the OpenCode plugin (global config dir)
|
|
151
|
-
runlist install
|
|
152
|
+
runlist install opencode --remove
|
|
152
153
|
runlist install opencode --path <dir> write to a specific plugin directory
|
|
153
154
|
runlist install opencode --force overwrite a runlist.js runlist did not write
|
|
154
155
|
|
|
@@ -168,6 +169,12 @@ host gets that a different way:
|
|
|
168
169
|
under extraKnownMarketplaces with a source that no longer matches;
|
|
169
170
|
runlist names the field but never edits that file.
|
|
170
171
|
|
|
172
|
+
codex Copies the npm-packaged runlist-codex plugin into the personal
|
|
173
|
+
marketplace and invokes \`codex plugin add\`. It supplies a compact
|
|
174
|
+
skill, SessionStart orientation, and a PreToolUse guard. Codex
|
|
175
|
+
requires a separate hook trust review; start a new thread after
|
|
176
|
+
installing. CODEX_THREAD_ID remains the ownership identity.
|
|
177
|
+
|
|
171
178
|
opencode Writes one auto-discovered plugin file. OpenCode has no plugin
|
|
172
179
|
registry but globs \`{plugin,plugins}/*.{ts,js}\` under its global
|
|
173
180
|
config dir, so no opencode.json edit is needed. It supplies two
|
|
@@ -302,7 +309,7 @@ Create & Export:
|
|
|
302
309
|
|
|
303
310
|
Setup:
|
|
304
311
|
init Create starter config + docs directory
|
|
305
|
-
install [opencode]
|
|
312
|
+
install [claude|codex|opencode] Install the agent-host integration (no arg = status)
|
|
306
313
|
update [--check|--cli-only|--plugin-only] Update the CLI + Claude Code plugin (--check reports skew, no network)
|
|
307
314
|
statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
|
|
308
315
|
help statuses Full status vocabulary + unstuck-actions + transitions
|
|
@@ -849,7 +856,7 @@ Modes:
|
|
|
849
856
|
--migrate-prompts Retrofit pre-existing markdown files under any docs
|
|
850
857
|
root's prompts/ subdirectory with proper prompt
|
|
851
858
|
frontmatter (type, status, created from git
|
|
852
|
-
history,
|
|
859
|
+
history, runlist_version, context, related_plans).
|
|
853
860
|
Skips files that already have frontmatter.
|
|
854
861
|
--migrate-template <file> Migrate just one plan.
|
|
855
862
|
--migrate-template --include-archived
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.1",
|
|
4
4
|
"description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"bin/",
|
|
17
17
|
"src/",
|
|
18
18
|
"assets/",
|
|
19
|
+
"plugins/runlist-codex/",
|
|
19
20
|
"scripts/postinstall.mjs",
|
|
20
21
|
"runlist.config.example.mjs"
|
|
21
22
|
],
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "runlist-codex",
|
|
3
|
+
"version": "0.89.1",
|
|
4
|
+
"description": "Runlist workflow skill, session orientation, and tool guard for Codex",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Reo Owens"
|
|
7
|
+
},
|
|
8
|
+
"skills": "./skills/",
|
|
9
|
+
"interface": {
|
|
10
|
+
"displayName": "Runlist Codex",
|
|
11
|
+
"shortDescription": "Plan, handoff, and document workflow for Codex.",
|
|
12
|
+
"longDescription": "Adds a concise runlist skill, session primer, and prompt/status guard hooks for managed Markdown repositories.",
|
|
13
|
+
"developerName": "Reo Owens",
|
|
14
|
+
"category": "Productivity",
|
|
15
|
+
"capabilities": [],
|
|
16
|
+
"defaultPrompt": "Show my runlist plans and pending handoffs."
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# runlist Codex plugin hook wrapper.
|
|
3
|
+
#
|
|
4
|
+
# The plugin's hooks drive the whole workflow through the PATH `runlist` binary
|
|
5
|
+
# (`runlist hud`, `runlist guard`), falling back to the legacy `dotmd` alias. If a user enables the plugin but hasn't run
|
|
6
|
+
# `npm i -g dotmd-cli`, the binary is missing, the hook errors `command not
|
|
7
|
+
# found`, and the session is primed with *silent nothing* — no signal that the
|
|
8
|
+
# whole workflow is one install away. runlist can't warn about its own absence
|
|
9
|
+
# (it's the thing that's missing), so the check lives here, outside the binary.
|
|
10
|
+
#
|
|
11
|
+
# Usage: runlist-hook [--hint] <runlist-subcommand> [args...]
|
|
12
|
+
# --hint emit a one-line install hint to stdout when no CLI is on PATH.
|
|
13
|
+
# Only SessionStart passes this, so the hint surfaces once per hook
|
|
14
|
+
# invocation (stdout becomes session context), never on a tool call.
|
|
15
|
+
#
|
|
16
|
+
# When the CLI is missing we always exit 0 with no further output: hooks must
|
|
17
|
+
# stay non-blocking, and the PreToolUse guard in particular must never deny a
|
|
18
|
+
# tool just because the optional CLI isn't installed.
|
|
19
|
+
hint=0
|
|
20
|
+
if [ "$1" = "--hint" ]; then
|
|
21
|
+
hint=1
|
|
22
|
+
shift
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
if command -v runlist >/dev/null 2>&1; then
|
|
26
|
+
CLI=runlist
|
|
27
|
+
elif command -v dotmd >/dev/null 2>&1; then
|
|
28
|
+
CLI=dotmd
|
|
29
|
+
else
|
|
30
|
+
if [ "$hint" = "1" ]; then
|
|
31
|
+
echo "runlist plugin: neither \`runlist\` nor \`dotmd\` is on PATH, so its hooks are no-ops. Enable the workflow with: npm i -g dotmd-cli"
|
|
32
|
+
fi
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# PreToolUse fast path: the guard fires on matched Bash, apply_patch, and MCP
|
|
37
|
+
# read calls, and
|
|
38
|
+
# a full Node boot (~100ms: CLI startup + config discovery) is pure overhead for
|
|
39
|
+
# the vast majority of payloads no guard rule can possibly match. Every rule
|
|
40
|
+
# needs a `.md` path AND either a prompts/ path (commit/cat/read-prompt) or the
|
|
41
|
+
# word `status` (edit-status) somewhere in the payload — a cheap shell glob
|
|
42
|
+
# decides that without leaving sh. Anything else gets the no-opinion reply
|
|
43
|
+
# (`{}`) directly. False positives here just pay the old cost (Node runs and
|
|
44
|
+
# returns {}); false negatives are impossible because the globs are strictly
|
|
45
|
+
# weaker than the rules they gate.
|
|
46
|
+
if [ "$1" = "guard" ]; then
|
|
47
|
+
payload=$(cat)
|
|
48
|
+
# Broad Git forms do not name a .md file in the payload; Node inspects the
|
|
49
|
+
# actual eligible/staged path set before deciding whether a live prompt is
|
|
50
|
+
# included. Always route those forms through the guard.
|
|
51
|
+
case "$payload" in
|
|
52
|
+
*git*add*|*git*stage*|*git*commit*)
|
|
53
|
+
printf '%s' "$payload" | "$CLI" "$@"
|
|
54
|
+
exit $?
|
|
55
|
+
;;
|
|
56
|
+
esac
|
|
57
|
+
case "$payload" in
|
|
58
|
+
*.md*)
|
|
59
|
+
case "$payload" in
|
|
60
|
+
*prompts/*|*prompts\\*|*status*)
|
|
61
|
+
printf '%s' "$payload" | "$CLI" "$@"
|
|
62
|
+
exit $?
|
|
63
|
+
;;
|
|
64
|
+
esac
|
|
65
|
+
;;
|
|
66
|
+
esac
|
|
67
|
+
printf '{}\n'
|
|
68
|
+
exit 0
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# UserPromptSubmit fast path is retained for compatibility with the Claude
|
|
72
|
+
# wrapper; Codex does not register that hook here. It would fire on every
|
|
73
|
+
# message that asks for a handoff gets an answer. The glob is strictly weaker
|
|
74
|
+
# than the Node-side match, so a skipped payload could never have matched.
|
|
75
|
+
if [ "$1" = "hud" ] && [ "$2" = "--prompt-submit" ]; then
|
|
76
|
+
payload=$(cat)
|
|
77
|
+
case "$payload" in
|
|
78
|
+
*[Bb][Aa][Tt][Oo][Nn]*|*[Hh][Aa][Nn][Dd]*[Oo][Ff][Ff]*|*[Rr][Ee][Ss][Uu][Mm][Ee]*|*[Pp][Ii][Cc][Kk]*[Uu][Pp]*)
|
|
79
|
+
printf '%s' "$payload" | "$CLI" "$@"
|
|
80
|
+
exit 0
|
|
81
|
+
;;
|
|
82
|
+
esac
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
exec "$CLI" "$@"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "startup|resume|clear|compact",
|
|
6
|
+
"hooks": [{ "type": "command", "command": "sh \"${PLUGIN_ROOT}/bin/runlist-hook\" --hint hud", "timeout": 5 }]
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"PreToolUse": [
|
|
10
|
+
{
|
|
11
|
+
"matcher": "Bash|apply_patch|mcp__.*__(read_file|read_text_file|read_media_file|read_multiple_files)",
|
|
12
|
+
"hooks": [{ "type": "command", "command": "sh \"${PLUGIN_ROOT}/bin/runlist-hook\" guard", "timeout": 5 }]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: runlist
|
|
3
|
+
description: Manage a repository's plans, docs, decisions, and saved prompts with the runlist CLI. Use when starting or closing plan work, checking the plan queue, or creating or consuming a handoff.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Runlist workflow
|
|
7
|
+
|
|
8
|
+
Use this skill in repositories with `runlist.config.mjs` or `dotmd.config.mjs`. The CLI is `runlist` (from `dotmd-cli`; `dotmd` remains an alias). The session hook prints a short live orientation. `CODEX_THREAD_ID` is the session identity used for plan claims; do not replace it with a process ID.
|
|
9
|
+
|
|
10
|
+
- Orient with `runlist plans`. Use `runlist agent-context` for structured state; `runlist briefing` is the comprehensive view and can be large.
|
|
11
|
+
- Start plan work with `runlist use <plan-file>`. It claims the plan, marks it `in-session`, and prints the card.
|
|
12
|
+
- Change status with `runlist set <status> <file> [--note "why"]`; this runs validation, lifecycle hooks, ref repairs, and index updates. Never hand-edit a `status:` field.
|
|
13
|
+
- Close a shipped plan with `runlist archive <file>`; use `partial`, `active`, `awaiting`, or `blocked` when those match the actual state. Valid statuses are repository and type specific: `runlist statuses list --type plan`.
|
|
14
|
+
- For a handoff, write a concrete resume draft, then run `runlist baton [<plan-or-slug>] @<draft-file>`. An owned plan is released in the same operation. If a handoff for that work is pending, inspect it with `runlist prompts show <file>`; keep it if current or pass `--replace` with the new draft. Replacement archives the old text and refreshes exactly one pending prompt.
|
|
15
|
+
- Consume a saved prompt with `runlist use <prompt-file>` (or bare `runlist use` for the oldest). This archives and claims before printing the body. For read-only triage, use `runlist prompts show <file>` or `runlist prompts show --all`; never open pending prompts with a file reader or `cat`.
|
|
16
|
+
- Pending prompts are session-local. Commit the tracked plan or source files named by baton, not `docs/prompts/*.md`.
|
|
17
|
+
- Create a document with `runlist new <type> <name> @<draft-file>`. A draft starting with frontmatter can supply scaffold fields; `type` is fixed by the command.
|
|
18
|
+
- Record a decision with `runlist new decision <plan> --question "…" @<record-file>`; configured registers may also require `--answers`.
|
|
19
|
+
- If a lifecycle mutation reports an abandoned transaction, inspect `runlist doctor --transactions` before retrying. Use `--apply` only for cases the doctor identifies as recoverable.
|
|
20
|
+
|
|
21
|
+
The `PreToolUse` hook warns on direct pending-prompt reads and denies hand-edited status transitions or Git operations that would include a live prompt. A repo can opt into blocking direct reads of existing pending prompts with `guard: { promptReads: 'deny' }`. Archived prompts and `runlist prompts show` remain readable. Codex requires explicit trust of a plugin's hooks before they run.
|
|
@@ -13,6 +13,11 @@ export const archiveDir = 'archived';
|
|
|
13
13
|
// Directories to skip when scanning
|
|
14
14
|
export const excludeDirs = ['evidence'];
|
|
15
15
|
|
|
16
|
+
// Agent-host tool guard: direct reads of existing pending prompts warn by
|
|
17
|
+
// default. Set promptReads to 'deny' to block those reads before execution;
|
|
18
|
+
// `runlist prompts show` and archived prompt reads remain available.
|
|
19
|
+
// export const guard = { promptReads: 'deny' };
|
|
20
|
+
|
|
16
21
|
// Floor under the scan surface. `runlist check` fails when it scans fewer docs than
|
|
17
22
|
// this, so a broken root or an over-eager exclude can't read as a clean estate —
|
|
18
23
|
// zero errors and zero docs look identical otherwise. Off when unset. Set it well
|
package/src/atomic-mutation.mjs
CHANGED
|
@@ -1159,7 +1159,7 @@ function reserveExclusive(filePath, mode, content, testHooks = {}) {
|
|
|
1159
1159
|
fd = openSync(filePath, 'wx', mode);
|
|
1160
1160
|
const opened = fstatSync(fd, { bigint: true });
|
|
1161
1161
|
openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
1162
|
-
const reservationContent = content ?? JSON.stringify({
|
|
1162
|
+
const reservationContent = content ?? JSON.stringify({ runlistReservation: true, pid: process.pid, createdAt: new Date().toISOString() }) + '\n';
|
|
1163
1163
|
writeFileSync(fd, reservationContent, 'utf8');
|
|
1164
1164
|
testHooks.afterReserveWrite?.(filePath);
|
|
1165
1165
|
testHooks.beforeReserveFsync?.(filePath);
|
|
@@ -1357,7 +1357,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1357
1357
|
for (const item of creations) ensureTransactionDirectory(path.dirname(item.path), transaction, options, item.path);
|
|
1358
1358
|
try {
|
|
1359
1359
|
reservation = reserveExclusive(targetPath, source.identity.mode, JSON.stringify({
|
|
1360
|
-
|
|
1360
|
+
runlistReservation: true,
|
|
1361
1361
|
transactionId,
|
|
1362
1362
|
sourcePath,
|
|
1363
1363
|
backup,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Codex's default personal marketplace is discovered without a CLI registration.
|
|
2
|
+
// The npm package carries this plugin so installation does not need a Git checkout.
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'plugins', 'runlist-codex');
|
|
9
|
+
const NAME = 'runlist-codex';
|
|
10
|
+
const MARKER = '.runlist-generated.json';
|
|
11
|
+
|
|
12
|
+
export function codexPaths(homedir = os.homedir()) {
|
|
13
|
+
const root = path.join(homedir, '.agents', 'plugins');
|
|
14
|
+
// The implicit personal marketplace file lives under ~/.agents/plugins,
|
|
15
|
+
// but Codex resolves its `./plugins/...` sources relative to the home dir.
|
|
16
|
+
return { root, marketplace: path.join(root, 'marketplace.json'), plugin: path.join(homedir, 'plugins', NAME) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function marketplaceAt(file) {
|
|
20
|
+
if (!existsSync(file)) return { name: 'personal', interface: { displayName: 'Personal' }, plugins: [] };
|
|
21
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
22
|
+
if (!data || typeof data.name !== 'string' || !Array.isArray(data.plugins)) {
|
|
23
|
+
throw new Error(`${file} is not a valid personal marketplace`);
|
|
24
|
+
}
|
|
25
|
+
return data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function codexStatus({ version, homedir = os.homedir() } = {}) {
|
|
29
|
+
const paths = codexPaths(homedir);
|
|
30
|
+
let installed = null;
|
|
31
|
+
try { installed = JSON.parse(readFileSync(path.join(paths.plugin, MARKER), 'utf8')).version; } catch { /* absent or foreign */ }
|
|
32
|
+
const exists = existsSync(paths.plugin);
|
|
33
|
+
return { ...paths, exists, foreign: exists && !installed, version: installed, stale: Boolean(installed && version && installed !== version) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function installCodexPlugin({ version, homedir = os.homedir(), dryRun = false } = {}) {
|
|
37
|
+
const sourceVersion = JSON.parse(readFileSync(path.join(SOURCE, '.codex-plugin', 'plugin.json'), 'utf8')).version;
|
|
38
|
+
if (sourceVersion !== version) throw new Error(`Codex plugin source is ${sourceVersion}, CLI is ${version}`);
|
|
39
|
+
const status = codexStatus({ version, homedir });
|
|
40
|
+
if (status.foreign) return { ...status, action: 'refused', reason: 'plugin directory exists without a runlist marker' };
|
|
41
|
+
let market;
|
|
42
|
+
try { market = marketplaceAt(status.marketplace); }
|
|
43
|
+
catch (err) { return { ...status, action: 'refused', reason: err.message }; }
|
|
44
|
+
const expected = `./plugins/${NAME}`;
|
|
45
|
+
const entry = market.plugins.find(p => p.name === NAME);
|
|
46
|
+
if (entry && (entry.source?.source !== 'local' || entry.source?.path !== expected)) {
|
|
47
|
+
return { ...status, action: 'refused', reason: 'marketplace entry points to a different plugin source' };
|
|
48
|
+
}
|
|
49
|
+
if (!entry) market.plugins.push({
|
|
50
|
+
name: NAME, source: { source: 'local', path: expected },
|
|
51
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity',
|
|
52
|
+
});
|
|
53
|
+
if (status.exists && !status.stale && entry) return { ...status, action: 'current', marketplaceName: market.name };
|
|
54
|
+
if (dryRun) return { ...status, action: status.exists ? 'updated' : 'installed', marketplaceName: market.name, dryRun: true };
|
|
55
|
+
|
|
56
|
+
const parent = path.dirname(status.plugin);
|
|
57
|
+
mkdirSync(parent, { recursive: true });
|
|
58
|
+
mkdirSync(path.dirname(status.marketplace), { recursive: true });
|
|
59
|
+
const stage = mkdtempSync(path.join(parent, '.runlist-codex-stage-'));
|
|
60
|
+
const backup = `${stage}-previous`;
|
|
61
|
+
const marketStage = `${status.marketplace}.runlist-stage-${process.pid}`;
|
|
62
|
+
let movedOld = false;
|
|
63
|
+
let published = false;
|
|
64
|
+
try {
|
|
65
|
+
cpSync(SOURCE, stage, { recursive: true });
|
|
66
|
+
writeFileSync(path.join(stage, MARKER), JSON.stringify({ version }) + '\n');
|
|
67
|
+
writeFileSync(marketStage, JSON.stringify(market, null, 2) + '\n');
|
|
68
|
+
if (status.exists) { renameSync(status.plugin, backup); movedOld = true; }
|
|
69
|
+
renameSync(stage, status.plugin);
|
|
70
|
+
published = true;
|
|
71
|
+
renameSync(marketStage, status.marketplace);
|
|
72
|
+
if (movedOld) rmSync(backup, { recursive: true, force: true });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (published) rmSync(status.plugin, { recursive: true, force: true });
|
|
75
|
+
if (movedOld) renameSync(backup, status.plugin);
|
|
76
|
+
rmSync(stage, { recursive: true, force: true });
|
|
77
|
+
rmSync(marketStage, { force: true });
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
return { ...codexStatus({ version, homedir }), action: status.exists ? 'updated' : 'installed', marketplaceName: market.name };
|
|
81
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -147,9 +147,9 @@ const DEFAULTS = {
|
|
|
147
147
|
// and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
|
|
148
148
|
journal: false,
|
|
149
149
|
|
|
150
|
-
// PreToolUse guard behavior. `deny: false` drops
|
|
151
|
-
// deny
|
|
152
|
-
guard: { deny: true },
|
|
150
|
+
// PreToolUse guard behavior. `deny: false` drops status edits to warnings;
|
|
151
|
+
// `promptReads: 'deny'` blocks direct reads of existing pending prompts.
|
|
152
|
+
guard: { deny: true, promptReads: 'warn' },
|
|
153
153
|
|
|
154
154
|
presets: {
|
|
155
155
|
stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
|
|
@@ -667,8 +667,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
667
667
|
moduleRequiredStatuses,
|
|
668
668
|
|
|
669
669
|
indexPath,
|
|
670
|
-
indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:
|
|
671
|
-
indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:
|
|
670
|
+
indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:runlist:start -->',
|
|
671
|
+
indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:runlist:end -->',
|
|
672
672
|
indexSnapshot: config.index?.snapshot ?? 'status',
|
|
673
673
|
archivedHighlightLimit: config.index?.archivedLimit ?? 8,
|
|
674
674
|
|
|
@@ -678,7 +678,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
678
678
|
presets: config.presets,
|
|
679
679
|
configuredPresetNames: new Set(Object.keys(userConfig.presets ?? {})),
|
|
680
680
|
journal: config.journal === true,
|
|
681
|
-
guard: {
|
|
681
|
+
guard: {
|
|
682
|
+
deny: config.guard?.deny !== false,
|
|
683
|
+
promptReads: config.guard?.promptReads === 'deny' ? 'deny' : 'warn',
|
|
684
|
+
},
|
|
682
685
|
hooks,
|
|
683
686
|
configWarnings,
|
|
684
687
|
};
|
package/src/export.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export function runExport(argv, config, opts = {}) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
} else if (format === 'html') {
|
|
102
|
-
const outDir = output ?? 'dotmd-export';
|
|
102
|
+
const outDir = output ?? (existsSync('dotmd-export') ? 'dotmd-export' : 'runlist-export');
|
|
103
103
|
const identities = allocateOutputIdentities(index.docs);
|
|
104
104
|
exportHtml(docsWithBody, config, outDir, identities, { dryRun });
|
|
105
105
|
if (dryRun) {
|
package/src/guard.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { inspectGitCommandPaths } from './git.mjs';
|
|
5
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
6
|
import { recordGuardEvent } from './journal.mjs';
|
|
6
7
|
import { readEnv } from './naming.mjs';
|
|
7
8
|
|
|
@@ -241,7 +242,26 @@ const STREAM_EDITOR_INPLACE = [
|
|
|
241
242
|
/\bg?awk\b[^|;&<>]*\binplace\b/,
|
|
242
243
|
];
|
|
243
244
|
|
|
244
|
-
function
|
|
245
|
+
function promptReadDecision(filePath, config, deps = {}) {
|
|
246
|
+
if (config?.guard?.promptReads !== 'deny') return 'warn';
|
|
247
|
+
if (!isManagedDoc(filePath, config)) return 'warn';
|
|
248
|
+
const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
249
|
+
try {
|
|
250
|
+
const absolute = path.resolve(deps.gitCwd ?? process.cwd(), filePath);
|
|
251
|
+
const real = p => { try { return realpathSync(p); } catch { return p; } };
|
|
252
|
+
const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : ['docs']);
|
|
253
|
+
const insideRoot = roots.some(root => {
|
|
254
|
+
const base = real(path.resolve(config.repoRoot ?? process.cwd(), root));
|
|
255
|
+
const relative = path.relative(base, real(absolute));
|
|
256
|
+
return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
257
|
+
});
|
|
258
|
+
if (!insideRoot) return 'warn';
|
|
259
|
+
const fm = parseSimpleFrontmatter(extractFrontmatter(readFile(absolute)).frontmatter);
|
|
260
|
+
return fm.type === 'prompt' && fm.status === 'pending' ? 'deny' : 'warn';
|
|
261
|
+
} catch { return 'warn'; }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function evalBash(command, config, inspectGitPaths, baseCwd, deps = {}) {
|
|
245
265
|
const segments = shellSegments(command);
|
|
246
266
|
let cwd = baseCwd;
|
|
247
267
|
|
|
@@ -279,7 +299,7 @@ function evalBash(command, config, inspectGitPaths, baseCwd) {
|
|
|
279
299
|
// Rule B — reading a prompt through the shell instead of consuming it.
|
|
280
300
|
if (SHELL_READERS.has(cmd0) && promptTokens.length) {
|
|
281
301
|
return {
|
|
282
|
-
decision:
|
|
302
|
+
decision: promptReadDecision(promptTokens[0], config, { ...deps, gitCwd: cwd }),
|
|
283
303
|
rule: 'cat-prompt',
|
|
284
304
|
detail: `${cmd0} ${promptTokens.join(' ')}`,
|
|
285
305
|
reason:
|
|
@@ -301,10 +321,10 @@ function evalBash(command, config, inspectGitPaths, baseCwd) {
|
|
|
301
321
|
return null;
|
|
302
322
|
}
|
|
303
323
|
|
|
304
|
-
function evalRead(filePath, config) {
|
|
324
|
+
function evalRead(filePath, config, deps = {}) {
|
|
305
325
|
if (!isPromptPath(filePath, config)) return null;
|
|
306
326
|
return {
|
|
307
|
-
decision:
|
|
327
|
+
decision: promptReadDecision(filePath, config, deps),
|
|
308
328
|
rule: 'read-prompt',
|
|
309
329
|
detail: filePath,
|
|
310
330
|
reason:
|
|
@@ -314,6 +334,31 @@ function evalRead(filePath, config) {
|
|
|
314
334
|
};
|
|
315
335
|
}
|
|
316
336
|
|
|
337
|
+
// Codex and OpenCode expose patch edits as one string. Only +/- hunk lines
|
|
338
|
+
// count: context mentioning status is an anchor, not an edit. New files are
|
|
339
|
+
// creation, and status on a deleted file is not a hand-edited transition.
|
|
340
|
+
function evalPatch(command, config) {
|
|
341
|
+
if (typeof command !== 'string') return null;
|
|
342
|
+
let target = null;
|
|
343
|
+
let removed = [];
|
|
344
|
+
let added = [];
|
|
345
|
+
const changed = () => target && statusLines(removed.join('\n')).join('\n') !== statusLines(added.join('\n')).join('\n');
|
|
346
|
+
for (const line of command.split('\n')) {
|
|
347
|
+
const update = line.match(/^\*\*\* Update File: (.+)$/);
|
|
348
|
+
if (update || /^\*\*\* (?:Add|Delete) File: /.test(line) || line === '*** End Patch') {
|
|
349
|
+
if (changed()) return editStatusResult(target, config, target);
|
|
350
|
+
target = update && isManagedDoc(update[1], config) ? update[1] : null;
|
|
351
|
+
removed = [];
|
|
352
|
+
added = [];
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!target || line.startsWith('***')) continue;
|
|
356
|
+
if (line.startsWith('+')) added.push(line.slice(1));
|
|
357
|
+
else if (line.startsWith('-')) removed.push(line.slice(1));
|
|
358
|
+
}
|
|
359
|
+
return changed() ? editStatusResult(target, config, target) : null;
|
|
360
|
+
}
|
|
361
|
+
|
|
317
362
|
// Every `status:` line in a snippet, normalized for comparison.
|
|
318
363
|
function statusLines(s) {
|
|
319
364
|
if (typeof s !== 'string') return [];
|
|
@@ -359,8 +404,16 @@ export function evaluateGuard(payload, config, deps = {}) {
|
|
|
359
404
|
const inspectGitPaths = deps.inspectGitPaths
|
|
360
405
|
|| ((subcommand, args, cwd) => inspectGitCommandPaths(subcommand, args, cwd ?? deps.gitCwd ?? process.cwd()));
|
|
361
406
|
|
|
362
|
-
if (tool === 'Bash') return evalBash(input.command || '', config, inspectGitPaths, deps.gitCwd ?? process.cwd());
|
|
363
|
-
if (tool === 'Read') return evalRead(input.file_path || '', config);
|
|
407
|
+
if (tool === 'Bash') return evalBash(input.command || input.cmd || '', config, inspectGitPaths, input.workdir ?? deps.gitCwd ?? process.cwd(), deps);
|
|
408
|
+
if (tool === 'Read') return evalRead(input.file_path || '', config, deps);
|
|
409
|
+
if (tool === 'apply_patch') return evalPatch(input.command || input.patchText || '', config);
|
|
410
|
+
if (/^mcp__.*__(?:read_file|read_text_file|read_media_file|read_multiple_files)$/.test(tool ?? '')) {
|
|
411
|
+
const paths = Array.isArray(input.paths) ? input.paths : [input.path ?? input.file_path];
|
|
412
|
+
for (const p of paths) {
|
|
413
|
+
const result = evalRead(p, config, deps);
|
|
414
|
+
if (result) return result;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
364
417
|
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
|
|
365
418
|
return null;
|
|
366
419
|
}
|
package/src/hub-membership.mjs
CHANGED
|
@@ -273,7 +273,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
273
273
|
warnings.push({
|
|
274
274
|
path: child.path,
|
|
275
275
|
level: 'warning',
|
|
276
|
-
message: `is ranked in the body order of \`${hub.path}\` (
|
|
276
|
+
message: `is ranked in the body order of \`${hub.path}\` (used by \`runlist runlist next\`) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
|
|
277
277
|
meta: { kind: BACKREF_KIND, hub: hub.path, source: 'body-order' },
|
|
278
278
|
});
|
|
279
279
|
}
|
package/src/index-file.mjs
CHANGED
|
@@ -5,15 +5,29 @@ import { formatSnapshot } from './render.mjs';
|
|
|
5
5
|
import { authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
6
6
|
import { mutateFile } from './atomic-mutation.mjs';
|
|
7
7
|
|
|
8
|
+
function indexMarkers(current, config) {
|
|
9
|
+
const start = config.indexStartMarker;
|
|
10
|
+
const end = config.indexEndMarker;
|
|
11
|
+
if (current.includes(start) && current.includes(end)) return { start, end };
|
|
12
|
+
// A config that omitted markers may point at an index from an older release.
|
|
13
|
+
// Keep updating that block in place; newly initialized indexes use runlist.
|
|
14
|
+
if (start === '<!-- GENERATED:runlist:start -->' && end === '<!-- GENERATED:runlist:end -->'
|
|
15
|
+
&& current.includes('<!-- GENERATED:dotmd:start -->') && current.includes('<!-- GENERATED:dotmd:end -->')) {
|
|
16
|
+
return { start: '<!-- GENERATED:dotmd:start -->', end: '<!-- GENERATED:dotmd:end -->' };
|
|
17
|
+
}
|
|
18
|
+
return { start, end };
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
export function renderIndexFile(index, config, current = readFileSync(config.indexPath, 'utf8')) {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
22
|
+
const markers = indexMarkers(current, config);
|
|
23
|
+
const start = current.indexOf(markers.start);
|
|
24
|
+
const end = current.indexOf(markers.end);
|
|
11
25
|
|
|
12
26
|
if (start === -1 || end === -1 || end < start) {
|
|
13
27
|
throw new Error(`${config.indexPath} is missing generated block markers.`);
|
|
14
28
|
}
|
|
15
29
|
|
|
16
|
-
const before = current.slice(0, start +
|
|
30
|
+
const before = current.slice(0, start + markers.start.length);
|
|
17
31
|
const after = current.slice(end);
|
|
18
32
|
const generated = `\n\n${renderGeneratedBlock(index, config)}\n`;
|
|
19
33
|
return `${before}${generated}${after}`;
|
|
@@ -130,8 +144,9 @@ export function checkIndex(docs, config, opts = {}) {
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
const current = readFileSync(config.indexPath, 'utf8');
|
|
133
|
-
const
|
|
134
|
-
const
|
|
147
|
+
const markers = indexMarkers(current, config);
|
|
148
|
+
const start = current.indexOf(markers.start);
|
|
149
|
+
const end = current.indexOf(markers.end);
|
|
135
150
|
|
|
136
151
|
if (start === -1 || end === -1 || end < start) {
|
|
137
152
|
errors.push({ path: config.indexPath, level: 'error', message: 'Missing generated index block markers.' });
|
package/src/init.mjs
CHANGED
|
@@ -53,8 +53,8 @@ export const root = 'docs';
|
|
|
53
53
|
|
|
54
54
|
export const index = {
|
|
55
55
|
path: 'docs/docs.md',
|
|
56
|
-
startMarker: '<!-- GENERATED:
|
|
57
|
-
endMarker: '<!-- GENERATED:
|
|
56
|
+
startMarker: '<!-- GENERATED:runlist:start -->',
|
|
57
|
+
endMarker: '<!-- GENERATED:runlist:end -->',
|
|
58
58
|
archivedLimit: 8,
|
|
59
59
|
};
|
|
60
60
|
|
|
@@ -118,11 +118,11 @@ against the type, and runs the lifecycle hooks.
|
|
|
118
118
|
|
|
119
119
|
const STARTER_INDEX = `# Docs
|
|
120
120
|
|
|
121
|
-
<!-- GENERATED:
|
|
121
|
+
<!-- GENERATED:runlist:start -->
|
|
122
122
|
|
|
123
123
|
_No docs yet. Run \`runlist list\` after creating your first document._
|
|
124
124
|
|
|
125
|
-
<!-- GENERATED:
|
|
125
|
+
<!-- GENERATED:runlist:end -->
|
|
126
126
|
`;
|
|
127
127
|
|
|
128
128
|
function scanExistingDocs(dir) {
|
|
@@ -263,8 +263,8 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
263
263
|
|
|
264
264
|
lines.push('export const index = {');
|
|
265
265
|
lines.push(` path: '${rootPath}/docs.md',`);
|
|
266
|
-
lines.push(` startMarker: '<!-- GENERATED:
|
|
267
|
-
lines.push(` endMarker: '<!-- GENERATED:
|
|
266
|
+
lines.push(` startMarker: '<!-- GENERATED:runlist:start -->',`);
|
|
267
|
+
lines.push(` endMarker: '<!-- GENERATED:runlist:end -->',`);
|
|
268
268
|
lines.push(` snapshot: 'status',`);
|
|
269
269
|
lines.push('};');
|
|
270
270
|
lines.push('');
|
package/src/install.mjs
CHANGED
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
} from './host-integration.mjs';
|
|
10
10
|
import { readInstalledPlugin } from './update.mjs';
|
|
11
11
|
import { executableName, which } from './util.mjs';
|
|
12
|
+
import { codexStatus, installCodexPlugin } from './codex-integration.mjs';
|
|
12
13
|
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
15
16
|
|
|
16
|
-
const HOSTS = ['claude', 'opencode'];
|
|
17
|
+
const HOSTS = ['claude', 'codex', 'opencode'];
|
|
17
18
|
const ALIASES = { 'claude-code': 'claude', claudecode: 'claude' };
|
|
18
19
|
|
|
19
20
|
function flagValue(argv, name) {
|
|
@@ -33,6 +34,7 @@ function hostStates() {
|
|
|
33
34
|
marketplaceRegistered: plugin ? plugin.marketplaceRegistered : null,
|
|
34
35
|
},
|
|
35
36
|
opencode: { ...opencodeStatus({ version: pkg.version }), detected: opencodeDetected() },
|
|
37
|
+
codex: codexStatus({ version: pkg.version }),
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -42,7 +44,7 @@ function reportStatus(json) {
|
|
|
42
44
|
process.stdout.write(JSON.stringify({ cli: pkg.version, hosts: states }, null, 2) + '\n');
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
45
|
-
const { claude, opencode } = states;
|
|
47
|
+
const { claude, codex, opencode } = states;
|
|
46
48
|
process.stdout.write(`${bold('runlist host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
|
|
47
49
|
|
|
48
50
|
const claudeBroken = claude.installed && claude.marketplaceRegistered === false;
|
|
@@ -52,6 +54,13 @@ function reportStatus(json) {
|
|
|
52
54
|
process.stdout.write(` claude ${claudeState}\n`);
|
|
53
55
|
process.stdout.write(` ${dim(claude.installed ? claude.id : 'plugin: SessionStart primer, PreToolUse guard, workflow skill')}\n`);
|
|
54
56
|
|
|
57
|
+
const codexState = codex.foreign ? yellow('unmanaged directory present')
|
|
58
|
+
: !codex.exists ? yellow('not installed')
|
|
59
|
+
: codex.stale ? yellow(`${codex.version} — behind CLI ${pkg.version}`)
|
|
60
|
+
: green(codex.version);
|
|
61
|
+
process.stdout.write(` codex ${codexState}\n`);
|
|
62
|
+
process.stdout.write(` ${dim(codex.plugin)}\n`);
|
|
63
|
+
|
|
55
64
|
const ocState = opencode.foreign ? yellow('unmanaged file present')
|
|
56
65
|
: !opencode.exists ? yellow('not installed')
|
|
57
66
|
: opencode.stale ? yellow(`${opencode.version} — behind CLI ${pkg.version}`)
|
|
@@ -61,6 +70,7 @@ function reportStatus(json) {
|
|
|
61
70
|
|
|
62
71
|
const todo = [];
|
|
63
72
|
if (!claude.installed || claudeBroken) todo.push('runlist install claude');
|
|
73
|
+
if (!codex.exists || codex.stale) todo.push('runlist install codex');
|
|
64
74
|
if (!opencode.exists || opencode.stale) todo.push('runlist install opencode');
|
|
65
75
|
if (todo.length) {
|
|
66
76
|
process.stdout.write('\n');
|
|
@@ -73,6 +83,43 @@ function reportStatus(json) {
|
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
function installCodex(argv, dryRun, json) {
|
|
87
|
+
if (argv.includes('--remove')) {
|
|
88
|
+
process.stderr.write('Codex removal is managed by `codex plugin remove`; the personal marketplace source is left available.\n');
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const result = installCodexPlugin({ version: pkg.version, dryRun });
|
|
93
|
+
if (result.action === 'refused') {
|
|
94
|
+
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
95
|
+
else process.stderr.write(`refused: ${result.reason}\n`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!json) process.stdout.write(`${dryRun ? '[dry-run] ' : ''}${result.action} codex integration (${pkg.version}) at ${result.plugin}\n`);
|
|
100
|
+
if (dryRun) {
|
|
101
|
+
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const selector = `runlist-codex@${result.marketplaceName}`;
|
|
105
|
+
if (!which('codex')) {
|
|
106
|
+
if (json) process.stdout.write(JSON.stringify({ ...result, pluginAdd: 'codex-cli-unavailable' }, null, 2) + '\n');
|
|
107
|
+
else process.stdout.write(`Install in Codex with: codex plugin add ${selector}\n`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const added = spawnSync(executableName('codex'), ['plugin', 'add', selector], {
|
|
111
|
+
stdio: json ? 'pipe' : 'inherit', encoding: json ? 'utf8' : undefined,
|
|
112
|
+
shell: process.platform === 'win32',
|
|
113
|
+
});
|
|
114
|
+
if (json) process.stdout.write(JSON.stringify({ ...result, pluginAdd: added.status === 0 ? 'installed' : 'failed', pluginAddExit: added.status }, null, 2) + '\n');
|
|
115
|
+
if (added.status !== 0) {
|
|
116
|
+
process.stderr.write(`Codex plugin source is ready; finish installation with: codex plugin add ${selector}\n`);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
} else if (!json) {
|
|
119
|
+
process.stdout.write('Start a new Codex thread and review/trust the plugin hooks to enable the primer and guard.\n');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
76
123
|
function installClaude(argv, dryRun, json) {
|
|
77
124
|
const remove = argv.includes('--remove');
|
|
78
125
|
const steps = planClaudeInstall({ installed: readInstalledPlugin(), hasClaude: which('claude'), remove });
|
|
@@ -164,5 +211,6 @@ export function runInstall(argv, _config, opts = {}) {
|
|
|
164
211
|
|
|
165
212
|
const dryRun = Boolean(opts.dryRun);
|
|
166
213
|
if (host === 'claude') { installClaude(argv, dryRun, json); return; }
|
|
214
|
+
if (host === 'codex') { installCodex(argv, dryRun, json); return; }
|
|
167
215
|
installOpencode(argv, dryRun, json);
|
|
168
216
|
}
|
package/src/migrate-prompts.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export function migrateOnePrompt(raw, opts = {}) {
|
|
|
64
64
|
'type: prompt',
|
|
65
65
|
'status: pending',
|
|
66
66
|
`created: ${created}`,
|
|
67
|
-
`
|
|
67
|
+
`runlist_version: ${pkg.version}`,
|
|
68
68
|
`context: "${context.replace(/"/g, '\\"')}"`,
|
|
69
69
|
'related_plans: []',
|
|
70
70
|
].join('\n');
|
|
@@ -86,7 +86,7 @@ export function migrateOnePrompt(raw, opts = {}) {
|
|
|
86
86
|
if (!parsed.type) needed.push(['type', 'prompt']);
|
|
87
87
|
if (!parsed.status) needed.push(['status', 'pending']);
|
|
88
88
|
if (!parsed.created) needed.push(['created', created]);
|
|
89
|
-
if (!parsed.dotmd_version) needed.push(['
|
|
89
|
+
if (!parsed.runlist_version && !parsed.dotmd_version) needed.push(['runlist_version', pkg.version]);
|
|
90
90
|
if (!parsed.context) {
|
|
91
91
|
// Prefer existing `title:` if present, else derive from body/filename.
|
|
92
92
|
const ctx = typeof parsed.title === 'string' ? parsed.title : deriveContext(rawBody || raw, opts.filePath || 'unknown');
|
package/src/new.mjs
CHANGED
|
@@ -237,7 +237,7 @@ Status markers (put in heading text):
|
|
|
237
237
|
`status: ${s}`,
|
|
238
238
|
`created: ${d}`,
|
|
239
239
|
`updated: ${d}`,
|
|
240
|
-
`
|
|
240
|
+
`runlist_version: ${pkg.version}`,
|
|
241
241
|
`context: ${ctx?.title ? `"${ctx.title.replace(/"/g, '\\"')}"` : ''}`,
|
|
242
242
|
'related_plans:',
|
|
243
243
|
].join('\n'),
|
package/src/update.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { green, dim, yellow } from './color.mjs';
|
|
7
7
|
import { executableName, which } from './util.mjs';
|
|
8
8
|
import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
|
|
9
|
+
import { codexStatus } from './codex-integration.mjs';
|
|
9
10
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -129,6 +130,11 @@ export function planUpdate(opts, ctx) {
|
|
|
129
130
|
} else if (ctx.opencode?.foreign) {
|
|
130
131
|
steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by runlist — leaving it alone` });
|
|
131
132
|
}
|
|
133
|
+
if (ctx.codex?.foreign) {
|
|
134
|
+
steps.push({ kind: 'skip', reason: `${ctx.codex.plugin} was not written by runlist — leaving it alone` });
|
|
135
|
+
} else if (ctx.codex?.exists && (ctx.codex.stale || (!opts.pluginOnly && ctx.hasNpm))) {
|
|
136
|
+
steps.push({ kind: 'codex', cmd: ['runlist', 'install', 'codex'] });
|
|
137
|
+
}
|
|
132
138
|
}
|
|
133
139
|
return steps;
|
|
134
140
|
}
|
|
@@ -158,11 +164,16 @@ export function runUpdate(argv, _config, opts = {}) {
|
|
|
158
164
|
if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
|
|
159
165
|
else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
|
|
160
166
|
else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
|
|
167
|
+
const cx = codexStatus({ version: pkg.version });
|
|
168
|
+
if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
|
|
169
|
+
else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
|
|
170
|
+
else process.stdout.write(`runlist codex: ${cx.version} ${cx.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
|
|
161
171
|
return;
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
const opencode = opencodeStatus({ version: pkg.version });
|
|
165
|
-
const
|
|
175
|
+
const codex = codexStatus({ version: pkg.version });
|
|
176
|
+
const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, opencode, codex, hasClaude: which('claude'), hasNpm: which('npm') });
|
|
166
177
|
if (opts.dryRun) {
|
|
167
178
|
for (const step of steps) {
|
|
168
179
|
if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
|