@gillcash/necktie 0.5.2 → 0.6.0

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.
@@ -1,14 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createRequire } from "node:module";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { parseCommandFile } from "./necktie-frontmatter.cjs";
5
+ import { resolveMode } from "../../lib/necktie-policy.cjs";
6
+ import { buildContext, executeModeCommand, parseModeArguments } from "../../lib/necktie-command.cjs";
7
+ import { readSessionMode, sessionIdentifier, writeSessionMode } from "../../lib/necktie-session.cjs";
5
8
 
6
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const require = createRequire(import.meta.url);
8
- const { parseCommandFile } = require("./necktie-frontmatter.cjs");
9
- const { buildInstructions, resolveMode, writeDefaultMode } = require("../../lib/necktie-policy.cjs");
10
- const { USAGE, formatStatus, parseModeArguments } = require("../../lib/necktie-command.cjs");
11
- const { readSessionMode, sessionIdentifier, writeSessionMode } = require("../../lib/necktie-session.cjs");
12
10
 
13
11
  const root = path.resolve(__dirname, "../..");
14
12
  const skillsDir = path.join(root, "skills");
@@ -24,35 +22,11 @@ export function activeMode(input = {}) {
24
22
  }
25
23
 
26
24
  export function handleModeCommand(input = {}) {
27
- const parsed = parseModeArguments(input.arguments);
28
- if (parsed.type === "invalid") return parsed.usage || USAGE;
29
25
  const key = sessionKey(input);
30
- let stored = readSessionMode("opencode", key);
31
-
32
- if (parsed.type === "set-session") {
33
- try {
34
- writeSessionMode("opencode", key, parsed.mode);
35
- return `Necktie mode set to ${parsed.mode} for this session.`;
36
- } catch (error) {
37
- return `Failed to save Necktie session mode: ${error.message}`;
38
- }
39
- }
40
- if (parsed.type === "set-default") {
41
- if (!stored) {
42
- stored = resolveMode().mode;
43
- try { writeSessionMode("opencode", key, stored); }
44
- catch (error) { return `Failed to initialize Necktie session mode: ${error.message}`; }
45
- }
46
- try {
47
- const written = writeDefaultMode(parsed.mode);
48
- return written.environmentOverride
49
- ? `Saved default ${written.writtenMode}, but NECKTIE_DEFAULT_MODE keeps the effective default at ${written.mode}. Current session remains ${stored}.`
50
- : `Default Necktie mode set to ${written.writtenMode} for new sessions. Current session remains ${stored}.`;
51
- } catch (error) {
52
- return `Failed to save Necktie default: ${error.message}. Current session remains ${stored}.`;
53
- }
54
- }
55
- return formatStatus(resolveMode({ sessionMode: stored }));
26
+ return executeModeCommand(parseModeArguments(input.arguments), {
27
+ sessionMode: readSessionMode("opencode", key),
28
+ saveSession: (mode) => writeSessionMode("opencode", key, mode),
29
+ }).message;
56
30
  }
57
31
 
58
32
  export default async function necktiePlugin({ client } = {}) {
@@ -84,12 +58,9 @@ export default async function necktiePlugin({ client } = {}) {
84
58
  "experimental.chat.system.transform": async (input, output) => {
85
59
  const resolution = activeMode(input);
86
60
  for (const warning of resolution.warnings) log(warning);
87
- const message = commandMessages.get(sessionKey(input));
88
- commandMessages.delete(sessionKey(input));
89
- const instructions = buildInstructions(resolution.mode, { root });
90
- const context = message
91
- ? `${message}\n\nAcknowledge this mode result concisely. Do not treat it as a decision request.\n\n${instructions}`
92
- : instructions;
61
+ const key = sessionKey(input);
62
+ const context = buildContext(resolution.mode, commandMessages.get(key), { root });
63
+ commandMessages.delete(key);
93
64
  if (output.system.length) output.system[output.system.length - 1] += `\n\n${context}`;
94
65
  else output.system.push(context);
95
66
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "necktie",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "An opinionated agent policy for decisions shaped by incentives, metrics, power, and hidden costs.",
5
5
  "author": { "name": "gillcash", "url": "https://github.com/gillcash" },
6
6
  "homepage": "https://github.com/gillcash/necktie",
package/NOTICE CHANGED
@@ -1,4 +1,4 @@
1
- Necktie 0.5.2
1
+ Necktie 0.6.0
2
2
  Copyright (c) 2026 gillcash
3
3
 
4
4
  Portions of Ponytail's cross-host packaging, adapter patterns, and test architecture are used under the MIT License.
package/README.md CHANGED
@@ -60,7 +60,7 @@ Necktie leads with a verdict or completed outcome, names the incentive or power
60
60
 
61
61
  The root `plugin.json` targets the [Agent Plugins 1.0.0 specification](https://agent-plugins.org/). The portable surface contains the `necktie` judgment skill and the `necktie-research` prompt-building skill.
62
62
 
63
- `skills/necktie/references/policy.md` is the canonical policy source. The build generates self-contained mode references plus one shared `core/` preamble and mode deltas. Static rules inject Full. Dynamic hooks compose the selected mode.
63
+ `skills/necktie/references/policy.md` is the canonical policy source. The build generates self-contained mode references shared by skills and runtime adapters. Static rules inject Full; dynamic hooks load the selected reference.
64
64
 
65
65
  `necktie-mcp/` is an optional private stdio adapter. Its `necktie` prompt and read-only `necktie_instructions` tool accept a mode per request. MCP does not activate Necktie on every turn and exposes no arbitrary repository, file, execution, network, or mutation operation. The process is not a sandbox: it reads Necktie's bundled policy and optional local default configuration.
66
66
 
@@ -167,6 +167,8 @@ python C:/Users/you/.codex/skills/.system/plugin-creator/scripts/validate_plugin
167
167
 
168
168
  `skills/necktie/references/policy.md` is the source for generated instruction artifacts and static adapters. Do not edit generated copies directly.
169
169
 
170
+ The [source map](docs/host-support.md#source-map) connects each entry point to its shared policy, command, and storage code. `npm test` also builds and checks the dependency-free website.
171
+
170
172
  ## License
171
173
 
172
174
  Necktie is available under the [MIT License](LICENSE). Third-party attribution is recorded in [NOTICE](NOTICE).
@@ -51,3 +51,26 @@ The explicit decision skill also accepts `$necktie --mode lite|full <decision>`
51
51
  A plugin cannot create a lifecycle event or state primitive that the host does not expose. Static rules provide Full only while the host reads the rule. MCP provides retrieval, not automatic activation. Session files used by lifecycle adapters contain only the selected mode, are keyed by a hash of host/session identity, and expire opportunistically using file age.
52
52
 
53
53
  Necktie must return one user-facing conclusion on every host. Modes never broaden permissions, authority, or acceptable risk.
54
+
55
+ ## Source map
56
+
57
+ | Responsibility | Source and callers |
58
+ | --- | --- |
59
+ | Policy text | `skills/necktie/references/policy.md` → `scripts/build-adapters.js` → shared mode references, static host rules, and standalone OpenClaw packages |
60
+ | Mode precedence and configuration | `lib/necktie-policy.cjs`; used by the JavaScript adapters and MCP |
61
+ | Command parsing, execution, and response text | `lib/necktie-command.cjs`; shared by lifecycle hooks, OpenCode, and Pi |
62
+ | Session storage | `lib/necktie-session.cjs` for hooks and OpenCode; Pi uses native session entries |
63
+ | Atomic JSON writes | `lib/necktie-json.cjs`; shared by configuration and session storage |
64
+ | Hermes | `__init__.py` registers host commands and hooks; `necktie_policy.py` resolves modes, stores defaults, and reads the same generated policy text |
65
+ | Research loop | `skills/necktie-research/scripts/research_prompt_loop.py` parses CLI commands; `research_state.py` validates packets, saves them, and enforces bounded transitions |
66
+ | Website | `website/` contains the static page, styles, assets, and dependency-free build and local preview commands |
67
+
68
+ Edit policy and research sources under `skills/`, then run `npm run build:adapters`.
69
+ The generator builds all expected outputs in memory before writing them, so one
70
+ pass repairs drift without reading stale generated policies. `--check` reports
71
+ missing, changed, and obsolete outputs without changing files. Generated copies
72
+ remain committed because hosts install from Git and OpenClaw skills travel alone.
73
+
74
+ Run `npm test` for adapter, MCP, Python, generator, and website checks.
75
+ JavaScript and Python retain native configuration implementations so installing
76
+ the Hermes adapter does not require Node merely to resolve a mode.
@@ -3,8 +3,8 @@
3
3
 
4
4
  const path = require("node:path");
5
5
 
6
- const { buildInstructions, resolveMode, writeDefaultMode } = require("../lib/necktie-policy.cjs");
7
- const { USAGE, formatStatus, parseModeCommand } = require("../lib/necktie-command.cjs");
6
+ const { resolveMode } = require("../lib/necktie-policy.cjs");
7
+ const { buildContext, executeModeCommand, parseModeCommand } = require("../lib/necktie-command.cjs");
8
8
  const { readSessionMode, sessionIdentifier, writeSessionMode } = require("../lib/necktie-session.cjs");
9
9
 
10
10
  function pluginRoot(env = process.env) {
@@ -20,66 +20,36 @@ function host(env = process.env) {
20
20
  }
21
21
 
22
22
  function promptText(input = {}) {
23
- for (const value of [input.prompt, input.text, input.userPrompt, input.user_prompt]) {
24
- if (typeof value === "string") return value.trim();
25
- }
26
- return "";
23
+ return [input.prompt, input.text, input.userPrompt, input.user_prompt]
24
+ .find((value) => typeof value === "string")?.trim() || "";
27
25
  }
28
26
 
29
27
  function evaluate(event, env = process.env, explicitHost = "", input = {}, options = {}) {
30
28
  const detectedHost = explicitHost || host(env);
31
29
  const identifier = sessionIdentifier(input, env, options.sessionOptions);
32
30
  let sessionMode = readSessionMode(detectedHost, identifier, options.sessionOptions);
33
- const initial = resolveMode({ sessionMode, env, configOptions: options.configOptions });
31
+ const saveSession = (mode) => writeSessionMode(detectedHost, identifier, mode, options.sessionOptions);
34
32
  let stateWarning = "";
35
33
 
36
34
  // Persist the initial default in session scope before handling a default write,
37
35
  // so `/necktie-mode default ...` never changes the current session implicitly.
38
36
  if (!sessionMode) {
37
+ sessionMode = resolveMode({ env, configOptions: options.configOptions }).mode;
39
38
  try {
40
- sessionMode = writeSessionMode(detectedHost, identifier, initial.mode, options.sessionOptions);
39
+ saveSession(sessionMode);
41
40
  } catch (error) {
42
- sessionMode = initial.mode;
43
41
  stateWarning = `Could not persist Necktie session mode: ${error.message}`;
44
42
  }
45
43
  }
46
44
 
47
45
  const parsed = event === "UserPromptSubmit" ? parseModeCommand(promptText(input)) : null;
48
- let message = "";
49
-
50
- if (parsed?.type === "set-session") {
51
- try {
52
- sessionMode = writeSessionMode(detectedHost, identifier, parsed.mode, options.sessionOptions);
53
- message = `Necktie mode set to ${sessionMode} for this session.`;
54
- } catch (error) {
55
- message = `Failed to save Necktie session mode: ${error.message}`;
56
- }
57
- } else if (parsed?.type === "set-default") {
58
- const before = resolveMode({ sessionMode, env, configOptions: options.configOptions });
59
- try {
60
- const written = writeDefaultMode(parsed.mode, env, options.configOptions);
61
- message = written.environmentOverride
62
- ? `Saved default ${written.writtenMode}, but NECKTIE_DEFAULT_MODE keeps the effective default at ${written.mode}. Current session remains ${before.mode}.`
63
- : `Default Necktie mode set to ${written.writtenMode} for new sessions. Current session remains ${before.mode}.`;
64
- } catch (error) {
65
- message = `Failed to save Necktie default: ${error.message}. Current session remains ${before.mode}.`;
66
- }
67
- } else if (parsed?.type === "invalid") {
68
- message = parsed.usage || USAGE;
69
- }
70
-
71
- const resolution = resolveMode({ sessionMode, env, configOptions: options.configOptions });
46
+ const { resolution, message } = executeModeCommand(parsed, {
47
+ sessionMode: stateWarning ? null : sessionMode, saveSession, env, configOptions: options.configOptions,
48
+ });
72
49
  if (stateWarning) resolution.warnings.push(stateWarning);
73
- if (parsed?.type === "status") message = formatStatus(resolution);
74
-
75
- const instructions = buildInstructions(resolution.mode, { root: pluginRoot(env) });
76
- const context = message
77
- ? `${message}\n\nAcknowledge this mode result concisely. Do not treat it as a decision request.\n\n${instructions}`
78
- : instructions;
79
-
80
50
  return {
81
51
  command: parsed,
82
- context,
52
+ context: buildContext(resolution.mode, message, { root: pluginRoot(env) }),
83
53
  host: detectedHost,
84
54
  message,
85
55
  resolution,
@@ -95,11 +65,6 @@ function hostPayload(event, context, detectedHost) {
95
65
  return context;
96
66
  }
97
67
 
98
- function payload(event, env = process.env, explicitHost = "", input = {}, options = {}) {
99
- const result = evaluate(event, env, explicitHost, input, options);
100
- return hostPayload(event, result.context, result.host);
101
- }
102
-
103
68
  function readHookInput(stream = process.stdin, timeoutMs = 1000) {
104
69
  if (!stream || stream.isTTY) return Promise.resolve({});
105
70
  return new Promise((resolve) => {
@@ -148,7 +113,6 @@ module.exports = {
148
113
  host,
149
114
  hostPayload,
150
115
  main,
151
- payload,
152
116
  pluginRoot,
153
117
  promptText,
154
118
  readHookInput,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const { normalizeMode } = require("./necktie-policy.cjs");
3
+ const { buildInstructions, normalizeMode, resolveMode, writeDefaultMode } = require("./necktie-policy.cjs");
4
4
 
5
5
  const USAGE = "Usage: /necktie-mode [status|lite|full|default <lite|full>]";
6
6
 
@@ -9,22 +9,17 @@ function extractArguments(text) {
9
9
  const marker = value.match(/^\[NECKTIE_MODE_COMMAND\][ \t]*([^\r\n]*)/i);
10
10
  if (marker) return marker[1].trim();
11
11
  const command = value.match(/^[/@$](?:[^\s:]+:)?necktie-mode(?:\s+([\s\S]*))?$/i);
12
- if (command) return String(command[1] || "").trim();
13
- return null;
12
+ return command ? (command[1] || "").trim() : null;
14
13
  }
15
14
 
16
15
  function parseModeArguments(rawArguments) {
17
16
  const raw = String(rawArguments || "").trim();
18
17
  if (!raw || raw.toLowerCase() === "status") return { type: "status" };
19
18
  const parts = raw.split(/\s+/);
20
- if (parts[0].toLowerCase() === "default") {
21
- if (parts.length !== 2) return { type: "invalid", usage: USAGE };
22
- const mode = normalizeMode(parts[1]);
23
- return mode ? { type: "set-default", mode } : { type: "invalid", usage: USAGE };
24
- }
25
- if (parts.length !== 1) return { type: "invalid", usage: USAGE };
26
- const mode = normalizeMode(parts[0]);
27
- return mode ? { type: "set-session", mode } : { type: "invalid", usage: USAGE };
19
+ const isDefault = parts[0].toLowerCase() === "default";
20
+ const mode = normalizeMode(parts.at(-1));
21
+ if (!mode || parts.length !== (isDefault ? 2 : 1)) return { type: "invalid", usage: USAGE };
22
+ return { type: isDefault ? "set-default" : "set-session", mode };
28
23
  }
29
24
 
30
25
  function parseModeCommand(text) {
@@ -40,4 +35,53 @@ function formatStatus(resolution) {
40
35
  return `Necktie mode: current ${resolution.mode}; configured default ${configuredDefault}.${override}`;
41
36
  }
42
37
 
43
- module.exports = { USAGE, extractArguments, formatStatus, parseModeArguments, parseModeCommand };
38
+ function executeModeCommand(command, { sessionMode, saveSession, env = process.env, configOptions } = {}) {
39
+ let resolution = resolveMode({ sessionMode, env, configOptions });
40
+ let message = "";
41
+ switch (command?.type) {
42
+ case "invalid":
43
+ message = command.usage || USAGE;
44
+ break;
45
+ case "status":
46
+ message = formatStatus(resolution);
47
+ break;
48
+ case "set-session":
49
+ try {
50
+ saveSession(command.mode);
51
+ resolution = resolveMode({ sessionMode: command.mode, env, configOptions });
52
+ message = `Necktie mode set to ${command.mode} for this session.`;
53
+ } catch (error) {
54
+ message = `Failed to save Necktie session mode: ${error.message}`;
55
+ }
56
+ break;
57
+ case "set-default":
58
+ // Pin the current default before changing what future sessions inherit.
59
+ try { saveSession(resolution.mode); }
60
+ catch (error) {
61
+ return { resolution, message: `Failed to initialize Necktie session mode: ${error.message}` };
62
+ }
63
+ try {
64
+ const written = writeDefaultMode(command.mode, env, configOptions);
65
+ resolution = resolveMode({ sessionMode: resolution.mode, env, configOptions });
66
+ message = written.environmentOverride
67
+ ? `Saved default ${written.writtenMode}, but NECKTIE_DEFAULT_MODE keeps the effective default at ${written.mode}.`
68
+ : `Default Necktie mode set to ${written.writtenMode} for new sessions.`;
69
+ } catch (error) {
70
+ message = `Failed to save Necktie default: ${error.message}.`;
71
+ }
72
+ message += ` Current session remains ${resolution.mode}.`;
73
+ break;
74
+ }
75
+ return { resolution, message };
76
+ }
77
+
78
+ function buildContext(mode, message = "", options = {}) {
79
+ const instructions = buildInstructions(mode, options);
80
+ return message
81
+ ? `${message}\n\nAcknowledge this mode result concisely. Do not treat it as a decision request.\n\n${instructions}`
82
+ : instructions;
83
+ }
84
+
85
+ module.exports = {
86
+ USAGE, buildContext, executeModeCommand, extractArguments, formatStatus, parseModeArguments, parseModeCommand,
87
+ };
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ const { randomUUID } = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+
7
+ function writeJson(target, value) {
8
+ fs.mkdirSync(path.dirname(target), { recursive: true });
9
+ const temporary = `${target}.${randomUUID()}.tmp`;
10
+ try {
11
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
12
+ encoding: "utf8", mode: 0o600, flag: "wx",
13
+ });
14
+ fs.renameSync(temporary, target);
15
+ } finally {
16
+ try { fs.rmSync(temporary, { force: true }); } catch (_) {}
17
+ }
18
+ }
19
+
20
+ module.exports = { writeJson };
@@ -3,6 +3,7 @@
3
3
  const fs = require("node:fs");
4
4
  const os = require("node:os");
5
5
  const path = require("node:path");
6
+ const { writeJson } = require("./necktie-json.cjs");
6
7
 
7
8
  const MODES = Object.freeze(["lite", "full", "mammon"]);
8
9
  const DEFAULT_MODE = "full";
@@ -49,28 +50,20 @@ function readConfig(env = process.env, options = {}) {
49
50
  }
50
51
 
51
52
  function resolveDefaultMode(env = process.env, options = {}) {
53
+ const loaded = readConfig(env, options);
52
54
  const warnings = [];
53
- let environmentMode = null;
54
55
  const environmentValue = env.NECKTIE_DEFAULT_MODE;
55
- if (environmentValue !== undefined) {
56
- environmentMode = normalizeMode(environmentValue);
57
- if (!environmentMode) warnings.push(`Ignored invalid NECKTIE_DEFAULT_MODE value: ${String(environmentValue)}.`);
56
+ const environmentMode = normalizeMode(environmentValue);
57
+ const configured = normalizeMode(loaded.config.defaultMode);
58
+ if (environmentValue !== undefined && !environmentMode) {
59
+ warnings.push(`Ignored invalid NECKTIE_DEFAULT_MODE value: ${String(environmentValue)}.`);
58
60
  }
59
-
60
- const loaded = readConfig(env, options);
61
61
  if (loaded.warning) warnings.push(loaded.warning);
62
- let configuredMode = DEFAULT_MODE;
63
- let configuredSource = "built-in";
64
- if (loaded.config.defaultMode !== undefined) {
65
- const configured = normalizeMode(loaded.config.defaultMode);
66
- if (configured) {
67
- configuredMode = configured;
68
- configuredSource = "config";
69
- } else {
70
- warnings.push(`Ignored invalid defaultMode in ${loaded.path}.`);
71
- }
62
+ if (loaded.config.defaultMode !== undefined && !configured) {
63
+ warnings.push(`Ignored invalid defaultMode in ${loaded.path}.`);
72
64
  }
73
-
65
+ const configuredMode = configured || DEFAULT_MODE;
66
+ const configuredSource = configured ? "config" : "built-in";
74
67
  return {
75
68
  mode: environmentMode || configuredMode,
76
69
  source: environmentMode ? "environment" : configuredSource,
@@ -85,44 +78,23 @@ function resolveDefaultMode(env = process.env, options = {}) {
85
78
  function resolveMode({ requestedMode, sessionMode, env = process.env, configOptions = {} } = {}) {
86
79
  const defaultResolution = resolveDefaultMode(env, configOptions);
87
80
  const warnings = [...defaultResolution.warnings];
88
-
81
+ let { mode, source } = defaultResolution;
89
82
  if (requestedMode !== undefined && requestedMode !== null) {
90
- const requested = normalizeMode(requestedMode);
91
- if (!requested) throw new InvalidModeError(requestedMode);
92
- return {
93
- mode: requested,
94
- source: "requested",
95
- defaultMode: defaultResolution.mode,
96
- defaultSource: defaultResolution.source,
97
- configuredDefaultMode: defaultResolution.configuredMode,
98
- configuredDefaultSource: defaultResolution.configuredSource,
99
- environmentOverride: defaultResolution.environmentOverride,
100
- configPath: defaultResolution.configPath,
101
- warnings,
102
- };
103
- }
104
-
105
- if (sessionMode !== undefined && sessionMode !== null && sessionMode !== "") {
83
+ mode = normalizeMode(requestedMode);
84
+ if (!mode) throw new InvalidModeError(requestedMode);
85
+ source = "requested";
86
+ } else if (sessionMode !== undefined && sessionMode !== null && sessionMode !== "") {
106
87
  const session = normalizeMode(sessionMode);
107
88
  if (session) {
108
- return {
109
- mode: session,
110
- source: "session",
111
- defaultMode: defaultResolution.mode,
112
- defaultSource: defaultResolution.source,
113
- configuredDefaultMode: defaultResolution.configuredMode,
114
- configuredDefaultSource: defaultResolution.configuredSource,
115
- environmentOverride: defaultResolution.environmentOverride,
116
- configPath: defaultResolution.configPath,
117
- warnings,
118
- };
89
+ mode = session;
90
+ source = "session";
91
+ } else {
92
+ warnings.push(`Ignored invalid stored Necktie session mode: ${String(sessionMode)}.`);
119
93
  }
120
- warnings.push(`Ignored invalid stored Necktie session mode: ${String(sessionMode)}.`);
121
94
  }
122
-
123
95
  return {
124
- mode: defaultResolution.mode,
125
- source: defaultResolution.source,
96
+ mode,
97
+ source,
126
98
  defaultMode: defaultResolution.mode,
127
99
  defaultSource: defaultResolution.source,
128
100
  configuredDefaultMode: defaultResolution.configuredMode,
@@ -137,25 +109,15 @@ function buildInstructions(mode = DEFAULT_MODE, options = {}) {
137
109
  const normalized = normalizeMode(mode);
138
110
  if (!normalized) throw new InvalidModeError(mode);
139
111
  const root = options.root ? path.resolve(options.root) : ROOT;
140
- const read = (name) => fs.readFileSync(path.join(root, "core", name), "utf8")
112
+ return fs.readFileSync(path.join(root, "skills/necktie/references", `${normalized}.md`), "utf8")
141
113
  .replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
142
- return `${read("necktie-core.md").replace("<MODE>", normalized)}\n\n${read(`necktie-${normalized}.md`)}`;
143
114
  }
144
115
 
145
116
  function writeDefaultMode(mode, env = process.env, options = {}) {
146
117
  const normalized = normalizeMode(mode);
147
118
  if (!normalized) throw new InvalidModeError(mode);
148
119
  const loaded = readConfig(env, options);
149
- const config = { ...loaded.config, defaultMode: normalized };
150
- const target = loaded.path;
151
- fs.mkdirSync(path.dirname(target), { recursive: true });
152
- const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
153
- try {
154
- fs.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
155
- fs.renameSync(temporary, target);
156
- } finally {
157
- try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch (_) {}
158
- }
120
+ writeJson(loaded.path, { ...loaded.config, defaultMode: normalized });
159
121
  return {
160
122
  writtenMode: normalized,
161
123
  ...resolveDefaultMode(env, options),
@@ -6,6 +6,7 @@ const os = require("node:os");
6
6
  const path = require("node:path");
7
7
 
8
8
  const { normalizeMode } = require("./necktie-policy.cjs");
9
+ const { writeJson } = require("./necktie-json.cjs");
9
10
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
10
11
 
11
12
  function stateDirectory(options = {}) {
@@ -61,18 +62,7 @@ function readSessionMode(host, sessionId, options = {}) {
61
62
  function writeSessionMode(host, sessionId, mode, options = {}) {
62
63
  const normalized = normalizeMode(mode);
63
64
  if (!normalized) return null;
64
- const target = statePath(host, sessionId, options);
65
- fs.mkdirSync(path.dirname(target), { recursive: true });
66
- const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
67
- try {
68
- fs.writeFileSync(temporary, `${JSON.stringify({ mode: normalized })}\n`, {
69
- encoding: "utf8",
70
- mode: 0o600,
71
- });
72
- fs.renameSync(temporary, target);
73
- } finally {
74
- try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch (_) {}
75
- }
65
+ writeJson(statePath(host, sessionId, options), { mode: normalized });
76
66
  return normalized;
77
67
  }
78
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gillcash/necktie",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "An opinionated agent policy for decisions shaped by incentives, metrics, power, and hidden costs.",
5
5
  "keywords": ["agent-plugin", "incentives", "power", "externalities", "opinionated-agent", "research-prompts"],
6
6
  "license": "MIT",
@@ -24,7 +24,6 @@
24
24
  "files": [
25
25
  "AGENTS.md",
26
26
  "plugin.json",
27
- "core/",
28
27
  "lib/",
29
28
  "hooks/",
30
29
  "skills/",
@@ -43,12 +42,12 @@
43
42
  "!pi-extension/test/**"
44
43
  ],
45
44
  "scripts": {
46
- "build:policy": "node scripts/build-policy.js",
47
- "build:adapters": "npm run build:policy && node scripts/build-adapters.js && node scripts/build-openclaw-skills.js",
48
- "check:policy": "node scripts/build-policy.js --check",
49
- "check:adapters": "npm run check:policy && node scripts/build-adapters.js --check && node scripts/build-openclaw-skills.js --check",
45
+ "build:policy": "node scripts/build-adapters.js",
46
+ "build:adapters": "node scripts/build-adapters.js",
47
+ "check:policy": "node scripts/build-adapters.js --check",
48
+ "check:adapters": "node scripts/build-adapters.js --check",
50
49
  "check:versions": "node scripts/check-versions.js",
51
- "test": "npm run check:adapters && npm run check:versions && node --test tests/*.test.js tests/*.test.mjs && npm test --prefix pi-extension && npm test --prefix necktie-mcp && python -m unittest discover -s tests -p test_*.py"
50
+ "test": "npm run check:adapters && npm run check:versions && node --test tests/*.test.js tests/*.test.mjs && npm test --prefix pi-extension && npm test --prefix necktie-mcp && python -m unittest discover -s tests -p test_*.py && npm test --prefix website"
52
51
  },
53
52
  "pi": {
54
53
  "extensions": ["./pi-extension/index.js"],
@@ -1,12 +1,7 @@
1
- import { createRequire } from "node:module";
1
+ import { DEFAULT_MODE, buildInstructions, normalizeMode, resolveMode } from "../lib/necktie-policy.cjs";
2
+ import { executeModeCommand, parseModeArguments } from "../lib/necktie-command.cjs";
2
3
 
3
- const require = createRequire(import.meta.url);
4
- const { DEFAULT_MODE, buildInstructions, normalizeMode, resolveMode, writeDefaultMode } = require("../lib/necktie-policy.cjs");
5
- const { USAGE, formatStatus, parseModeArguments } = require("../lib/necktie-command.cjs");
6
-
7
- export function coreContext(mode = DEFAULT_MODE) {
8
- return buildInstructions(mode);
9
- }
4
+ export { buildInstructions as coreContext, parseModeArguments as parseNecktieModeCommand };
10
5
 
11
6
  export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
12
7
  const fallback = normalizeMode(fallbackMode) || DEFAULT_MODE;
@@ -20,10 +15,6 @@ export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
20
15
  return fallback;
21
16
  }
22
17
 
23
- export function parseNecktieModeCommand(text) {
24
- return parseModeArguments(text);
25
- }
26
-
27
18
  export function sendSkill(pi, skill, args, ctx) {
28
19
  const suffix = String(args || "").trim();
29
20
  const message = suffix ? `/skill:${skill} ${suffix}` : `/skill:${skill}`;
@@ -32,8 +23,7 @@ export function sendSkill(pi, skill, args, ctx) {
32
23
  }
33
24
 
34
25
  export default function necktieExtension(pi) {
35
- let configuredDefault = resolveMode();
36
- let currentMode = configuredDefault.mode;
26
+ let currentMode = resolveMode().mode;
37
27
 
38
28
  pi.registerCommand("necktie", {
39
29
  description: "Run /skill:necktie",
@@ -44,37 +34,18 @@ export default function necktieExtension(pi) {
44
34
  description: "Set Necktie mode: lite or full. Commands: status, default <mode>",
45
35
  handler: async (args, ctx) => {
46
36
  const parsed = parseModeArguments(args);
47
- let message;
48
- if (parsed.type === "set-session") {
49
- try {
50
- pi.appendEntry?.("necktie-mode", { mode: parsed.mode });
51
- currentMode = parsed.mode;
52
- message = `Necktie mode set to ${currentMode} for this session.`;
53
- } catch (error) {
54
- message = `Failed to save Necktie session mode: ${error.message}`;
55
- }
56
- } else if (parsed.type === "set-default") {
57
- try {
58
- const written = writeDefaultMode(parsed.mode);
59
- configuredDefault = resolveMode();
60
- message = written.environmentOverride
61
- ? `Saved default ${written.writtenMode}, but NECKTIE_DEFAULT_MODE keeps the effective default at ${written.mode}. Current session remains ${currentMode}.`
62
- : `Default Necktie mode set to ${written.writtenMode} for new sessions. Current session remains ${currentMode}.`;
63
- } catch (error) {
64
- message = `Failed to save Necktie default: ${error.message}`;
65
- }
66
- } else if (parsed.type === "status") {
67
- message = formatStatus(resolveMode({ sessionMode: currentMode }));
68
- } else {
69
- message = parsed.usage || USAGE;
70
- }
37
+ const { resolution, message } = executeModeCommand(parsed, {
38
+ sessionMode: currentMode,
39
+ saveSession: (mode) => pi.appendEntry?.("necktie-mode", { mode }),
40
+ });
41
+ currentMode = resolution.mode;
71
42
  ctx?.ui?.notify?.(message, parsed.type === "invalid" ? "warning" : "info");
72
43
  return message;
73
44
  },
74
45
  });
75
46
 
76
47
  pi.on("session_start", async (_event, ctx) => {
77
- configuredDefault = resolveMode();
48
+ const configuredDefault = resolveMode();
78
49
  for (const warning of configuredDefault.warnings) ctx?.ui?.notify?.(warning, "warning");
79
50
  const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
80
51
  currentMode = resolveSessionMode(entries, configuredDefault.mode);
@@ -82,6 +53,6 @@ export default function necktieExtension(pi) {
82
53
 
83
54
  pi.on("before_agent_start", async (event) => {
84
55
  const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
85
- return { systemPrompt: `${base}${coreContext(currentMode)}` };
56
+ return { systemPrompt: `${base}${buildInstructions(currentMode)}` };
86
57
  });
87
58
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gillcash/necktie-pi-extension",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "necktie",
4
- "version": "0.5.2",
4
+ "version": "0.6.0",
5
5
  "description": "An opinionated agent policy for decisions shaped by incentives, metrics, power, and hidden costs.",
6
6
  "author": {
7
7
  "name": "gillcash",
@@ -1,298 +1,58 @@
1
1
  #!/usr/bin/env python3
2
2
  """Create and advance an auditable Necktie research-prompt run packet."""
3
3
 
4
- from __future__ import annotations
5
-
6
4
  import argparse
7
- from datetime import datetime, timezone
8
5
  import json
9
6
  from pathlib import Path
10
7
  import sys
11
- import uuid
12
-
13
-
14
- SCHEMA_VERSION = "1.0"
15
- REVISION_LIMITS = {"standard": 3, "deep": 5}
16
- ORIGIN_MODES = {"full", "mammon"}
17
- STATES = {
18
- "intake",
19
- "discover",
20
- "fingerprint",
21
- "critique",
22
- "blueprint",
23
- "draft",
24
- "review",
25
- "revise",
26
- "verify",
27
- "complete",
28
- "blocked",
29
- }
30
- ALLOWED_TRANSITIONS = {
31
- "intake": {"discover"},
32
- "discover": {"fingerprint"},
33
- "fingerprint": {"critique"},
34
- "critique": {"blueprint"},
35
- "blueprint": {"draft"},
36
- "draft": {"review"},
37
- "revise": {"review"},
38
- }
39
-
40
-
41
- class LoopError(ValueError):
42
- """Raised for an invalid run packet or state transition."""
43
-
44
-
45
- def utc_now() -> str:
46
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
47
-
48
-
49
- def event(kind: str, **details: object) -> dict[str, object]:
50
- return {"at": utc_now(), "kind": kind, **details}
51
-
52
-
53
- def new_packet(goal: str, depth: str, origin_mode: str) -> dict[str, object]:
54
- goal = goal.strip()
55
- if not goal:
56
- raise LoopError("goal must not be empty")
57
- if depth not in REVISION_LIMITS:
58
- raise LoopError(f"unsupported depth: {depth}")
59
- if origin_mode not in ORIGIN_MODES:
60
- raise LoopError(f"unsupported origin mode: {origin_mode}")
61
- now = utc_now()
62
- return {
63
- "schema_version": SCHEMA_VERSION,
64
- "run_id": str(uuid.uuid4()),
65
- "created_at": now,
66
- "updated_at": now,
67
- "goal": goal,
68
- "depth": depth,
69
- "origin_mode": origin_mode,
70
- "state": "intake",
71
- "audience": "",
72
- "target_deliverables": [],
73
- "acceptance_criteria": [],
74
- "constraints": [],
75
- "non_goals": [],
76
- "sources": [],
77
- "reference_fingerprint": {},
78
- "assumptions": [],
79
- "strongest_unasked_question": "",
80
- "prompt_path": "",
81
- "review_history": [],
82
- "verification_history": [],
83
- "circuit_breaker": None,
84
- "history": [event("initialized", state="intake")],
85
- }
86
-
87
-
88
- def validate_packet(packet: object) -> dict[str, object]:
89
- if not isinstance(packet, dict):
90
- raise LoopError("run packet must be a JSON object")
91
- required = {
92
- "schema_version",
93
- "run_id",
94
- "goal",
95
- "depth",
96
- "origin_mode",
97
- "state",
98
- "review_history",
99
- "verification_history",
100
- "history",
101
- }
102
- missing = sorted(required - packet.keys())
103
- if missing:
104
- raise LoopError(f"run packet is missing: {', '.join(missing)}")
105
- if packet["schema_version"] != SCHEMA_VERSION:
106
- raise LoopError(f"unsupported schema_version: {packet['schema_version']}")
107
- if packet["depth"] not in REVISION_LIMITS:
108
- raise LoopError(f"unsupported depth: {packet['depth']}")
109
- if packet["origin_mode"] not in ORIGIN_MODES:
110
- raise LoopError(f"unsupported origin mode: {packet['origin_mode']}")
111
- if packet["state"] not in STATES:
112
- raise LoopError(f"unsupported state: {packet['state']}")
113
- for key in ("review_history", "verification_history", "history"):
114
- if not isinstance(packet[key], list):
115
- raise LoopError(f"{key} must be an array")
116
- return packet
117
-
118
-
119
- def load_packet(path: Path) -> dict[str, object]:
120
- try:
121
- return validate_packet(json.loads(path.read_text(encoding="utf-8")))
122
- except FileNotFoundError as exc:
123
- raise LoopError(f"run packet not found: {path}") from exc
124
- except json.JSONDecodeError as exc:
125
- raise LoopError(f"invalid JSON in {path}: {exc}") from exc
126
-
127
8
 
128
- def save_packet(path: Path, packet: dict[str, object]) -> None:
129
- validate_packet(packet)
130
- packet["updated_at"] = utc_now()
131
- path.parent.mkdir(parents=True, exist_ok=True)
132
- temporary = path.with_suffix(path.suffix + ".tmp")
133
- temporary.write_text(json.dumps(packet, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
134
- temporary.replace(path)
9
+ from research_state import (
10
+ ORIGIN_MODES, REVISION_LIMITS, STATES, LoopError, load_packet, new_packet,
11
+ record_review, record_verification, save_packet, transition,
12
+ )
135
13
 
136
-
137
- def transition(packet: dict[str, object], target: str, note: str) -> None:
138
- current = str(packet["state"])
139
- if target not in ALLOWED_TRANSITIONS.get(current, set()):
140
- allowed = ", ".join(sorted(ALLOWED_TRANSITIONS.get(current, set()))) or "none"
141
- raise LoopError(f"cannot transition from {current} to {target}; allowed: {allowed}")
142
- packet["state"] = target
143
- packet["history"].append(event("transition", previous=current, state=target, note=note.strip()))
144
-
145
-
146
- def revision_count(packet: dict[str, object]) -> int:
147
- return sum(review["decision"] == "REVISE" for review in packet["review_history"])
148
-
149
-
150
- def record_review(
151
- packet: dict[str, object], decision: str, reason: str, issue_signature: str
152
- ) -> None:
153
- if packet["state"] != "review":
154
- raise LoopError(f"review decisions require state=review, found {packet['state']}")
155
- decision = decision.upper()
156
- if decision not in {"APPROVE", "REVISE", "BLOCK"}:
157
- raise LoopError(f"unsupported review decision: {decision}")
158
- reason = reason.strip()
159
- if not reason:
160
- raise LoopError("review reason must not be empty")
161
- signature = issue_signature.strip()
162
- if decision == "REVISE" and not signature:
163
- raise LoopError("REVISE requires --issue-signature")
164
-
165
- reviews = packet["review_history"]
166
- reviews.append(
167
- event(
168
- "review",
169
- attempt=len(reviews) + 1,
170
- decision=decision,
171
- reason=reason,
172
- issue_signature=signature,
173
- )
174
- )
175
-
176
- if decision == "APPROVE":
177
- packet["state"] = "verify"
178
- elif decision == "BLOCK":
179
- packet["state"] = "blocked"
180
- packet["circuit_breaker"] = "reviewer-blocked"
181
- else:
182
- same_issue_count = 0
183
- for review in reversed(reviews):
184
- if review["decision"] == "REVISE" and review["issue_signature"] == signature:
185
- same_issue_count += 1
186
- else:
187
- break
188
- if same_issue_count >= 3:
189
- packet["state"] = "blocked"
190
- packet["circuit_breaker"] = "same-issue-three-times"
191
- elif revision_count(packet) > REVISION_LIMITS[str(packet["depth"])]:
192
- packet["state"] = "blocked"
193
- packet["circuit_breaker"] = "revision-limit-exceeded"
194
- else:
195
- packet["state"] = "revise"
196
-
197
- packet["history"].append(
198
- event("review-decision", decision=decision, state=packet["state"], reason=reason)
199
- )
200
-
201
-
202
- def record_verification(
203
- packet: dict[str, object], result: str, reason: str, issue_signature: str
204
- ) -> None:
205
- if packet["state"] != "verify":
206
- raise LoopError(f"verification requires state=verify, found {packet['state']}")
207
- result = result.upper()
208
- if result not in {"PASS", "FAIL"}:
209
- raise LoopError(f"unsupported verification result: {result}")
210
- reason = reason.strip()
211
- if not reason:
212
- raise LoopError("verification reason must not be empty")
213
- signature = issue_signature.strip()
214
- if result == "FAIL" and not signature:
215
- raise LoopError("FAIL requires --issue-signature")
216
-
217
- verifications = packet["verification_history"]
218
- verifications.append(
219
- event(
220
- "verification",
221
- attempt=len(verifications) + 1,
222
- result=result,
223
- reason=reason,
224
- issue_signature=signature,
225
- )
226
- )
227
- if result == "PASS":
228
- packet["state"] = "complete"
229
- elif revision_count(packet) >= REVISION_LIMITS[str(packet["depth"])]:
230
- packet["state"] = "blocked"
231
- packet["circuit_breaker"] = "verification-failed-after-revision-limit"
232
- else:
233
- packet["state"] = "revise"
234
- packet["history"].append(
235
- event("verification-result", result=result, state=packet["state"], reason=reason)
236
- )
14
+ ACTIONS = {"transition": transition, "review": record_review, "verify": record_verification}
237
15
 
238
16
 
239
17
  def build_parser() -> argparse.ArgumentParser:
240
18
  parser = argparse.ArgumentParser(description=__doc__)
241
- subparsers = parser.add_subparsers(dest="command", required=True)
242
-
243
- initialize = subparsers.add_parser("init", help="create a new research-prompt run packet")
19
+ commands = parser.add_subparsers(dest="command", required=True)
20
+ initialize = commands.add_parser("init", help="create a research-prompt run packet")
244
21
  initialize.add_argument("--goal", required=True)
245
22
  initialize.add_argument("--depth", choices=sorted(REVISION_LIMITS), default="standard")
246
23
  initialize.add_argument("--origin-mode", choices=sorted(ORIGIN_MODES), default="full")
247
- initialize.add_argument("--output", type=Path, required=True)
248
-
249
- advance = subparsers.add_parser("transition", help="advance to an allowed phase")
250
- advance.add_argument("--file", type=Path, required=True)
251
- advance.add_argument("--to", choices=sorted(STATES), required=True)
252
- advance.add_argument("--note", default="")
253
-
254
- review = subparsers.add_parser("review", help="record a frozen-draft review decision")
255
- review.add_argument("--file", type=Path, required=True)
256
- review.add_argument("--decision", choices=["APPROVE", "REVISE", "BLOCK"], required=True)
257
- review.add_argument("--reason", required=True)
258
- review.add_argument("--issue-signature", default="")
259
-
260
- verify = subparsers.add_parser("verify", help="record fresh-session verification")
261
- verify.add_argument("--file", type=Path, required=True)
262
- verify.add_argument("--result", choices=["PASS", "FAIL"], required=True)
263
- verify.add_argument("--reason", required=True)
264
- verify.add_argument("--issue-signature", default="")
265
-
266
- show = subparsers.add_parser("show", help="validate and print a run packet")
267
- show.add_argument("--file", type=Path, required=True)
24
+ initialize.add_argument("--output", dest="file", type=Path, required=True)
25
+ parsers = {name: commands.add_parser(name, help=help_text) for name, help_text in {
26
+ "transition": "advance to an allowed phase",
27
+ "review": "record a frozen-draft review decision",
28
+ "verify": "record fresh-session verification",
29
+ "show": "validate and print a run packet",
30
+ }.items()}
31
+ for command in parsers.values():
32
+ command.add_argument("--file", type=Path, required=True)
33
+ parsers["transition"].add_argument("--to", dest="target", choices=sorted(STATES), required=True)
34
+ parsers["transition"].add_argument("--note", default="")
35
+ for name, field, choices in (("review", "decision", ("APPROVE", "REVISE", "BLOCK")),
36
+ ("verify", "result", ("PASS", "FAIL"))):
37
+ parsers[name].add_argument(f"--{field}", choices=choices, required=True)
38
+ parsers[name].add_argument("--reason", required=True)
39
+ parsers[name].add_argument("--issue-signature", default="")
268
40
  return parser
269
41
 
270
42
 
271
- def main(argv: list[str] | None = None) -> int:
272
- args = build_parser().parse_args(argv)
43
+ def main(argv=None) -> int:
44
+ options = vars(build_parser().parse_args(argv))
45
+ command, path = options.pop("command"), options.pop("file")
273
46
  try:
274
- if args.command == "init":
275
- packet = new_packet(args.goal, args.depth, args.origin_mode)
276
- save_packet(args.output, packet)
277
- print(f"initialized {packet['run_id']} at {args.output}")
278
- elif args.command == "transition":
279
- packet = load_packet(args.file)
280
- transition(packet, args.to, args.note)
281
- save_packet(args.file, packet)
282
- print(f"state={packet['state']}")
283
- elif args.command == "review":
284
- packet = load_packet(args.file)
285
- record_review(packet, args.decision, args.reason, args.issue_signature)
286
- save_packet(args.file, packet)
287
- print(f"state={packet['state']}")
288
- elif args.command == "verify":
289
- packet = load_packet(args.file)
290
- record_verification(packet, args.result, args.reason, args.issue_signature)
291
- save_packet(args.file, packet)
292
- print(f"state={packet['state']}")
293
- else:
294
- print(json.dumps(load_packet(args.file), indent=2, ensure_ascii=False))
295
- except LoopError as exc:
47
+ packet = new_packet(**options) if command == "init" else load_packet(path)
48
+ if command == "show":
49
+ print(json.dumps(packet, indent=2, ensure_ascii=False))
50
+ return 0
51
+ if command in ACTIONS:
52
+ ACTIONS[command](packet, **options)
53
+ save_packet(path, packet)
54
+ print(f"initialized {packet['run_id']} at {path}" if command == "init" else f"state={packet['state']}")
55
+ except (LoopError, OSError) as exc:
296
56
  print(f"error: {exc}", file=sys.stderr)
297
57
  return 2
298
58
  return 0
@@ -0,0 +1,152 @@
1
+ """Research packet persistence and bounded review/verification transitions."""
2
+
3
+ from contextlib import suppress
4
+ from datetime import datetime, timezone
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from tempfile import mkstemp
9
+ import uuid
10
+
11
+ SCHEMA_VERSION = "1.0"
12
+ REVISION_LIMITS = {"standard": 3, "deep": 5}
13
+ ORIGIN_MODES = {"full", "mammon"}
14
+ PHASES = "intake discover fingerprint critique blueprint draft review".split()
15
+ ALLOWED_TRANSITIONS = {current: {target} for current, target in zip(PHASES, PHASES[1:])}
16
+ ALLOWED_TRANSITIONS["revise"] = {"review"}
17
+ STATES = set(PHASES) | {"revise", "verify", "complete", "blocked"}
18
+ OUTCOMES = {
19
+ "review": {"APPROVE": "verify", "REVISE": "revise", "BLOCK": "blocked"},
20
+ "verification": {"PASS": "complete", "FAIL": "revise"},
21
+ }
22
+
23
+
24
+ class LoopError(ValueError):
25
+ """Invalid run packet or state transition."""
26
+
27
+
28
+ def utc_now() -> str:
29
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
30
+
31
+
32
+ def event(kind: str, **details) -> dict:
33
+ return {"at": utc_now(), "kind": kind, **details}
34
+
35
+
36
+ def new_packet(goal: str, depth: str, origin_mode: str) -> dict:
37
+ now = utc_now()
38
+ return validate_packet({
39
+ "schema_version": SCHEMA_VERSION, "run_id": str(uuid.uuid4()),
40
+ "created_at": now, "updated_at": now, "goal": goal.strip() if isinstance(goal, str) else goal,
41
+ "depth": depth, "origin_mode": origin_mode, "state": "intake",
42
+ **dict.fromkeys(("audience", "strongest_unasked_question", "prompt_path"), ""),
43
+ **{key: [] for key in (
44
+ "target_deliverables", "acceptance_criteria", "constraints", "non_goals",
45
+ "sources", "assumptions", "review_history", "verification_history",
46
+ )},
47
+ "reference_fingerprint": {}, "circuit_breaker": None,
48
+ "history": [event("initialized", state="intake")],
49
+ })
50
+
51
+
52
+ def validate_packet(packet: object) -> dict:
53
+ if not isinstance(packet, dict):
54
+ raise LoopError("run packet must be a JSON object")
55
+ choices = {"schema_version": {SCHEMA_VERSION}, "depth": REVISION_LIMITS,
56
+ "origin_mode": ORIGIN_MODES, "state": STATES}
57
+ for key in (*choices, "run_id", "goal"):
58
+ value = packet.get(key)
59
+ if not isinstance(value, str) or not value.strip():
60
+ raise LoopError(f"{key} must be a nonempty string")
61
+ if key in choices and value not in choices[key]:
62
+ raise LoopError(f"unsupported {key}: {value}")
63
+ for kind in ("review", "verification", ""):
64
+ key = f"{kind}_history" if kind else "history"
65
+ records = packet.get(key)
66
+ if not isinstance(records, list) or any(not isinstance(record, dict) for record in records):
67
+ raise LoopError(f"{key} must be an array of objects")
68
+ if kind:
69
+ field = "decision" if kind == "review" else "result"
70
+ for record in records:
71
+ value = record.get(field)
72
+ if not isinstance(value, str) or value not in OUTCOMES[kind]:
73
+ raise LoopError(f"invalid {field} in {key}")
74
+ signature = record.get("issue_signature")
75
+ if not isinstance(signature, str) or (value in {"REVISE", "FAIL"} and not signature.strip()):
76
+ raise LoopError(f"invalid issue_signature in {key}")
77
+ return packet
78
+
79
+
80
+ def load_packet(path: Path) -> dict:
81
+ try:
82
+ return validate_packet(json.loads(path.read_text(encoding="utf-8-sig")))
83
+ except (OSError, ValueError) as exc:
84
+ raise LoopError(f"cannot load run packet {path}: {exc}") from exc
85
+
86
+
87
+ def save_packet(path: Path, packet: dict) -> None:
88
+ validate_packet(packet)
89
+ packet["updated_at"] = utc_now()
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ descriptor, name = mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
92
+ pending = Path(name)
93
+ try:
94
+ with os.fdopen(descriptor, "w", encoding="utf-8") as temporary:
95
+ json.dump(packet, temporary, indent=2, ensure_ascii=False)
96
+ temporary.write("\n")
97
+ pending.replace(path)
98
+ finally:
99
+ with suppress(OSError):
100
+ pending.unlink(missing_ok=True)
101
+
102
+
103
+ def transition(packet: dict, target: str, note: str) -> None:
104
+ current = packet["state"]
105
+ allowed = ALLOWED_TRANSITIONS.get(current, set())
106
+ if target not in allowed:
107
+ raise LoopError(f"cannot transition from {current} to {target}; allowed: {', '.join(sorted(allowed)) or 'none'}")
108
+ packet["state"] = target
109
+ packet["history"].append(event("transition", previous=current, state=target, note=note.strip()))
110
+
111
+
112
+ def revision_count(packet: dict) -> int:
113
+ return (sum(review["decision"] == "REVISE" for review in packet["review_history"])
114
+ + sum(check["result"] == "FAIL" for check in packet["verification_history"]))
115
+
116
+
117
+ def _record(packet: dict, kind: str, value: str, reason: str, signature: str) -> None:
118
+ phase, field = ("review", "decision") if kind == "review" else ("verify", "result")
119
+ if packet["state"] != phase:
120
+ raise LoopError(f"{kind} requires state={phase}, found {packet['state']}")
121
+ value, reason, signature = value.upper(), reason.strip(), signature.strip()
122
+ if value not in OUTCOMES[kind]:
123
+ raise LoopError(f"unsupported {kind} {field}: {value}")
124
+ if not reason:
125
+ raise LoopError(f"{kind} reason must not be empty")
126
+ if value in {"REVISE", "FAIL"} and not signature:
127
+ raise LoopError(f"{value} requires --issue-signature")
128
+ records = packet[f"{kind}_history"]
129
+ records.append(event(kind, attempt=len(records) + 1, **{field: value}, reason=reason, issue_signature=signature))
130
+ breaker = None
131
+ revisions, limit = revision_count(packet), REVISION_LIMITS[packet["depth"]]
132
+ if value == "BLOCK":
133
+ breaker = "reviewer-blocked"
134
+ elif value == "REVISE":
135
+ if len(records) >= 3 and all(r["decision"] == "REVISE" and r["issue_signature"] == signature for r in records[-3:]):
136
+ breaker = "same-issue-three-times"
137
+ elif revisions > limit:
138
+ breaker = "revision-limit-exceeded"
139
+ elif value == "FAIL" and revisions > limit:
140
+ breaker = "verification-failed-after-revision-limit"
141
+ packet["state"] = "blocked" if breaker else OUTCOMES[kind][value]
142
+ if breaker:
143
+ packet["circuit_breaker"] = breaker
144
+ packet["history"].append(event(f"{kind}-{field}", **{field: value}, state=packet["state"], reason=reason))
145
+
146
+
147
+ def record_review(packet: dict, decision: str, reason: str, issue_signature: str) -> None:
148
+ _record(packet, "review", decision, reason, issue_signature)
149
+
150
+
151
+ def record_verification(packet: dict, result: str, reason: str, issue_signature: str) -> None:
152
+ _record(packet, "verification", result, reason, issue_signature)
@@ -1,17 +0,0 @@
1
- NECKTIE MODE ACTIVE — level: <MODE>.
2
-
3
- # Necktie Core
4
-
5
- Necktie is active for every response. Analysis only goes where the decision
6
- is material: who benefits, who pays, who decides, who can leave; value
7
- created vs. captured; costs hidden from the metric; behavior the incentive
8
- rewards; durable vs. fragile. Prefer human agency over metric worship, shared
9
- value over extraction, truth over convenient narrative, accountable power
10
- over opaque control. Take a side; don't manufacture disagreement.
11
-
12
- Proportionate, not preachy: no forced political commentary, no ideology over
13
- domain evidence. Never trade away security, privacy, accessibility, input
14
- validation, or data-loss protection. Never narrate private analysis or a
15
- debate transcript — surface the conclusion, the material tradeoff, and the
16
- evidence it needs. Lead with the outcome. Ask only when the answer changes
17
- the deliverable.
@@ -1,10 +0,0 @@
1
- Mammon as internal adversary, then rebut — plus two private passes:
2
-
3
- - Ambition pass: assume agent capability improves quickly. Is there a
4
- higher-leverage authorized build than the smallest intervention?
5
- - Useful action pass: when work is authorized, do it. When a material
6
- response ends at judgment, offer exactly one concrete next artifact
7
- (draft, research prompt, implementation).
8
-
9
- If that artifact is a research prompt, use `necktie-research`; approval
10
- authorizes starting immediately.
@@ -1,5 +0,0 @@
1
- Mammon is an internal adversary: build the strongest case for accumulation,
2
- growth, control, rent extraction, lock-in, surveillance, labor/attention
3
- leverage, and risk-shifting onto the less powerful. Then rebut it using the
4
- core questions above. Mammon stays internal; never present it as a second
5
- speaker or quoted dialogue.
@@ -1,15 +0,0 @@
1
- Mammon is the sole final perspective. Give the strongest evidence-based
2
- recommendation for accumulation, growth, control, market power, rent
3
- extraction, lock-in, surveillance, labor/attention leverage, and shifting
4
- cost and risk away from the decision-maker, with the legitimate efficiency,
5
- speed, scale, and coordination benefits. No rebuttal, no rebalancing toward
6
- shared value or human agency. Still strategically honest: flag legal,
7
- security, privacy, operational, labor, reputational, exit, and backlash
8
- risks; never fabricate evidence, conceal a material downside, exceed the
9
- user's authority, or bypass safety boundaries.
10
-
11
- Useful action pass: do authorized work. When a material response ends at
12
- judgment, offer exactly one concrete next artifact.
13
-
14
- If that artifact is a research prompt, use `necktie-research`; approval
15
- authorizes starting immediately.