@gillcash/necktie 0.2.0 → 0.4.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.
Files changed (53) hide show
  1. package/.opencode/command/necktie-mode.md +7 -0
  2. package/.opencode/command/necktie.md +2 -2
  3. package/.opencode/plugins/necktie.mjs +67 -5
  4. package/.qoder/rules/necktie.md +28 -10
  5. package/.qoder-plugin/plugin.json +3 -3
  6. package/AGENTS.md +28 -10
  7. package/LICENSE +20 -20
  8. package/NOTICE +2 -2
  9. package/README.es.md +46 -27
  10. package/README.ko.md +45 -26
  11. package/README.md +83 -118
  12. package/commands/necktie-mode.toml +5 -0
  13. package/commands/necktie.toml +2 -2
  14. package/core/necktie-core.md +28 -10
  15. package/core/necktie-full.md +36 -0
  16. package/core/necktie-lite.md +28 -0
  17. package/core/necktie-ultra.md +42 -0
  18. package/docs/host-support.md +50 -0
  19. package/docs/process-provenance.md +48 -0
  20. package/docs/release-notes-0.4.0.md +14 -0
  21. package/hooks/copilot-hooks.json +8 -0
  22. package/hooks/hooks.json +13 -2
  23. package/hooks/necktie-context.js +126 -14
  24. package/lib/necktie-command.cjs +44 -0
  25. package/lib/necktie-policy.cjs +177 -0
  26. package/lib/necktie-session.cjs +87 -0
  27. package/package.json +9 -5
  28. package/pi-extension/index.js +71 -13
  29. package/pi-extension/package.json +1 -1
  30. package/plugin.json +3 -3
  31. package/skills/necktie/SKILL.md +11 -81
  32. package/skills/necktie/agents/openai.yaml +3 -3
  33. package/skills/necktie/references/full.md +36 -0
  34. package/skills/necktie/references/lite.md +28 -0
  35. package/skills/necktie/references/policy.md +46 -0
  36. package/skills/necktie/references/ultra.md +42 -0
  37. package/.opencode/command/necktie-critique.md +0 -5
  38. package/.opencode/command/necktie-reverse.md +0 -5
  39. package/.opencode/command/necktie-review.md +0 -5
  40. package/commands/necktie-critique.toml +0 -2
  41. package/commands/necktie-reverse.toml +0 -2
  42. package/commands/necktie-review.toml +0 -2
  43. package/skills/necktie/references/loop-protocol.md +0 -85
  44. package/skills/necktie/scripts/necktie_loop.py +0 -199
  45. package/skills/necktie-critique/SKILL.md +0 -48
  46. package/skills/necktie-critique/agents/openai.yaml +0 -6
  47. package/skills/necktie-reverse/SKILL.md +0 -41
  48. package/skills/necktie-reverse/agents/openai.yaml +0 -6
  49. package/skills/necktie-reverse/references/blueprint-template.md +0 -54
  50. package/skills/necktie-review/SKILL.md +0 -61
  51. package/skills/necktie-review/agents/openai.yaml +0 -6
  52. package/skills/necktie-review/references/reviewer-rubric.md +0 -29
  53. package/skills/necktie-review/scripts/validate_review.py +0 -110
package/hooks/hooks.json CHANGED
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "node -e \"const r=process.argv[1],e=process.argv[2],g=e&&!e.startsWith('$'+'{')?e:'',p=g||r;require(p+'/hooks/necktie-context.js').main(['SessionStart',g])\" \"${CLAUDE_PLUGIN_ROOT}\" \"${extensionPath}\"",
10
- "additionalContextLimit": 1200
10
+ "additionalContextLimit": 2400
11
11
  }
12
12
  ]
13
13
  }
@@ -18,7 +18,18 @@
18
18
  {
19
19
  "type": "command",
20
20
  "command": "node -e \"const r=process.argv[1],e=process.argv[2],g=e&&!e.startsWith('$'+'{')?e:'',p=g||r;require(p+'/hooks/necktie-context.js').main(['SubagentStart',g])\" \"${CLAUDE_PLUGIN_ROOT}\" \"${extensionPath}\"",
21
- "additionalContextLimit": 1200
21
+ "additionalContextLimit": 2400
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "UserPromptSubmit": [
27
+ {
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node -e \"const r=process.argv[1],e=process.argv[2],g=e&&!e.startsWith('$'+'{')?e:'',p=g||r;require(p+'/hooks/necktie-context.js').main(['UserPromptSubmit',g])\" \"${CLAUDE_PLUGIN_ROOT}\" \"${extensionPath}\"",
32
+ "additionalContextLimit": 2400
22
33
  }
23
34
  ]
24
35
  }
@@ -1,17 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- const fs = require("node:fs");
5
4
  const path = require("node:path");
6
5
 
6
+ const { buildInstructions, resolveMode, writeDefaultMode } = require("../lib/necktie-policy.cjs");
7
+ const { USAGE, formatStatus, parseModeCommand } = require("../lib/necktie-command.cjs");
8
+ const { readSessionMode, sessionIdentifier, writeSessionMode } = require("../lib/necktie-session.cjs");
9
+
7
10
  function pluginRoot(env = process.env) {
8
11
  return env.PLUGIN_ROOT || env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, "..");
9
12
  }
10
13
 
11
- function coreContext(env = process.env) {
12
- return fs.readFileSync(path.join(pluginRoot(env), "core", "necktie-core.md"), "utf8").trim();
13
- }
14
-
15
14
  function host(env = process.env) {
16
15
  const compatibilityRoot = env.CLAUDE_PLUGIN_ROOT || "";
17
16
  if (env.COPILOT_PLUGIN_DATA || compatibilityRoot.includes(".vscode/agent-plugins")) return "copilot";
@@ -20,24 +19,137 @@ function host(env = process.env) {
20
19
  return "claude";
21
20
  }
22
21
 
23
- function payload(event, env = process.env, explicitHost = "") {
24
- const context = coreContext(env);
25
- const detected = explicitHost || host(env);
26
- if (detected === "copilot") return { additionalContext: context };
27
- if (detected === "codex" || detected === "qoder" || detected === "gemini" || event === "SubagentStart") {
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 "";
27
+ }
28
+
29
+ function evaluate(event, env = process.env, explicitHost = "", input = {}, options = {}) {
30
+ const detectedHost = explicitHost || host(env);
31
+ const identifier = sessionIdentifier(input, env, options.sessionOptions);
32
+ let sessionMode = readSessionMode(detectedHost, identifier, options.sessionOptions);
33
+ const initial = resolveMode({ sessionMode, env, configOptions: options.configOptions });
34
+ let stateWarning = "";
35
+
36
+ // Persist the initial default in session scope before handling a default write,
37
+ // so `/necktie-mode default ...` never changes the current session implicitly.
38
+ if (!sessionMode) {
39
+ try {
40
+ sessionMode = writeSessionMode(detectedHost, identifier, initial.mode, options.sessionOptions);
41
+ } catch (error) {
42
+ sessionMode = initial.mode;
43
+ stateWarning = `Could not persist Necktie session mode: ${error.message}`;
44
+ }
45
+ }
46
+
47
+ 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 });
72
+ 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
+ return {
81
+ command: parsed,
82
+ context,
83
+ host: detectedHost,
84
+ message,
85
+ resolution,
86
+ sessionId: identifier,
87
+ };
88
+ }
89
+
90
+ function hostPayload(event, context, detectedHost) {
91
+ if (detectedHost === "copilot") return { additionalContext: context };
92
+ if (detectedHost === "codex" || detectedHost === "qoder" || detectedHost === "gemini" || event === "SubagentStart") {
28
93
  return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
29
94
  }
30
95
  return context;
31
96
  }
32
97
 
33
- function main(argv = process.argv.slice(2), env = process.env) {
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
+ function readHookInput(stream = process.stdin, timeoutMs = 1000) {
104
+ if (!stream || stream.isTTY) return Promise.resolve({});
105
+ return new Promise((resolve) => {
106
+ let raw = "";
107
+ let finished = false;
108
+ let timer;
109
+ const onData = (chunk) => { raw += chunk; };
110
+ const finish = () => {
111
+ if (finished) return;
112
+ finished = true;
113
+ clearTimeout(timer);
114
+ stream.removeListener?.("data", onData);
115
+ stream.removeListener?.("end", finish);
116
+ stream.removeListener?.("error", finish);
117
+ stream.pause?.();
118
+ try {
119
+ resolve(raw.trim() ? JSON.parse(raw.replace(/^\uFEFF/, "")) : {});
120
+ } catch (_) {
121
+ resolve({});
122
+ }
123
+ };
124
+ stream.setEncoding?.("utf8");
125
+ stream.on("data", onData);
126
+ stream.on("end", finish);
127
+ stream.on("error", finish);
128
+ timer = setTimeout(finish, timeoutMs);
129
+ });
130
+ }
131
+
132
+ async function main(argv = process.argv.slice(2), env = process.env, options = {}) {
34
133
  const event = argv[0] || "SessionStart";
35
134
  const hint = argv[1] || "";
36
135
  const explicitHost = hint === "copilot" || hint === "qoder" ? hint : hint ? "gemini" : "";
37
- const result = payload(event, env, explicitHost);
136
+ const input = options.input || await readHookInput(options.stdin || process.stdin, options.timeoutMs || 1000);
137
+ const evaluated = evaluate(event, env, explicitHost, input, options);
138
+ for (const warning of evaluated.resolution.warnings) process.stderr.write(`${warning}\n`);
139
+ const result = hostPayload(event, evaluated.context, evaluated.host);
38
140
  process.stdout.write(typeof result === "string" ? result : JSON.stringify(result));
141
+ return result;
39
142
  }
40
143
 
41
- if (require.main === module) main();
144
+ if (require.main === module) main().catch(() => { process.exitCode = 0; });
42
145
 
43
- module.exports = { coreContext, host, main, payload, pluginRoot };
146
+ module.exports = {
147
+ evaluate,
148
+ host,
149
+ hostPayload,
150
+ main,
151
+ payload,
152
+ pluginRoot,
153
+ promptText,
154
+ readHookInput,
155
+ };
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+
3
+ const { MODES, normalizeMode } = require("./necktie-policy.cjs");
4
+
5
+ const MODE_LIST = MODES.join("|");
6
+ const USAGE = `Usage: /necktie-mode [status|${MODE_LIST}|default <${MODE_LIST}>]`;
7
+
8
+ function extractArguments(text) {
9
+ const value = String(text || "").trim();
10
+ const marker = value.match(/^\[NECKTIE_MODE_COMMAND\][ \t]*([^\r\n]*)/i);
11
+ if (marker) return marker[1].trim();
12
+ const command = value.match(/^[/@$](?:[^\s:]+:)?necktie-mode(?:\s+([\s\S]*))?$/i);
13
+ if (command) return String(command[1] || "").trim();
14
+ return null;
15
+ }
16
+
17
+ function parseModeArguments(rawArguments) {
18
+ const raw = String(rawArguments || "").trim();
19
+ if (!raw || raw.toLowerCase() === "status") return { type: "status" };
20
+ const parts = raw.split(/\s+/);
21
+ if (parts[0].toLowerCase() === "default") {
22
+ if (parts.length !== 2) return { type: "invalid", usage: USAGE };
23
+ const mode = normalizeMode(parts[1]);
24
+ return mode ? { type: "set-default", mode } : { type: "invalid", usage: USAGE };
25
+ }
26
+ if (parts.length !== 1) return { type: "invalid", usage: USAGE };
27
+ const mode = normalizeMode(parts[0]);
28
+ return mode ? { type: "set-session", mode } : { type: "invalid", usage: USAGE };
29
+ }
30
+
31
+ function parseModeCommand(text) {
32
+ const args = extractArguments(text);
33
+ return args === null ? null : parseModeArguments(args);
34
+ }
35
+
36
+ function formatStatus(resolution) {
37
+ const override = resolution.environmentOverride
38
+ ? ` Environment override: ${resolution.environmentOverride}.`
39
+ : "";
40
+ const configuredDefault = resolution.configuredDefaultMode || resolution.defaultMode;
41
+ return `Necktie mode: current ${resolution.mode}; configured default ${configuredDefault}.${override}`;
42
+ }
43
+
44
+ module.exports = { MODE_LIST, USAGE, extractArguments, formatStatus, parseModeArguments, parseModeCommand };
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ const MODES = Object.freeze(["lite", "full", "ultra"]);
8
+ const DEFAULT_MODE = "full";
9
+ const ROOT = path.resolve(__dirname, "..");
10
+
11
+ class InvalidModeError extends Error {
12
+ constructor(value) {
13
+ super(`Invalid Necktie mode: ${String(value)}. Expected lite, full, or ultra.`);
14
+ this.name = "InvalidModeError";
15
+ this.code = "NECKTIE_INVALID_MODE";
16
+ }
17
+ }
18
+
19
+ function normalizeMode(value) {
20
+ if (typeof value !== "string") return null;
21
+ const normalized = value.trim().toLowerCase();
22
+ return MODES.includes(normalized) ? normalized : null;
23
+ }
24
+
25
+ function configPath(env = process.env, options = {}) {
26
+ if (options.configPath) return path.resolve(options.configPath);
27
+ const platform = options.platform || process.platform;
28
+ const home = options.home || os.homedir();
29
+ if (platform === "win32") {
30
+ const base = env.APPDATA || path.win32.join(home, "AppData", "Roaming");
31
+ return path.win32.join(base, "necktie", "config.json");
32
+ }
33
+ if (env.XDG_CONFIG_HOME) return path.join(env.XDG_CONFIG_HOME, "necktie", "config.json");
34
+ return path.join(home, ".config", "necktie", "config.json");
35
+ }
36
+
37
+ function readConfig(env = process.env, options = {}) {
38
+ const target = configPath(env, options);
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8").replace(/^\uFEFF/, ""));
41
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
42
+ return { config: {}, warning: `Ignored non-object Necktie configuration at ${target}.`, path: target };
43
+ }
44
+ return { config: parsed, warning: null, path: target };
45
+ } catch (error) {
46
+ if (error?.code === "ENOENT") return { config: {}, warning: null, path: target };
47
+ return { config: {}, warning: `Ignored invalid Necktie configuration at ${target}.`, path: target };
48
+ }
49
+ }
50
+
51
+ function resolveDefaultMode(env = process.env, options = {}) {
52
+ const warnings = [];
53
+ let environmentMode = null;
54
+ 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)}.`);
58
+ }
59
+
60
+ const loaded = readConfig(env, options);
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
+ }
72
+ }
73
+
74
+ return {
75
+ mode: environmentMode || configuredMode,
76
+ source: environmentMode ? "environment" : configuredSource,
77
+ configuredMode,
78
+ configuredSource,
79
+ environmentOverride: environmentMode,
80
+ configPath: loaded.path,
81
+ warnings,
82
+ };
83
+ }
84
+
85
+ function resolveMode({ requestedMode, sessionMode, env = process.env, configOptions = {} } = {}) {
86
+ const defaultResolution = resolveDefaultMode(env, configOptions);
87
+ const warnings = [...defaultResolution.warnings];
88
+
89
+ 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 !== "") {
106
+ const session = normalizeMode(sessionMode);
107
+ 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
+ };
119
+ }
120
+ warnings.push(`Ignored invalid stored Necktie session mode: ${String(sessionMode)}.`);
121
+ }
122
+
123
+ return {
124
+ mode: defaultResolution.mode,
125
+ source: defaultResolution.source,
126
+ defaultMode: defaultResolution.mode,
127
+ defaultSource: defaultResolution.source,
128
+ configuredDefaultMode: defaultResolution.configuredMode,
129
+ configuredDefaultSource: defaultResolution.configuredSource,
130
+ environmentOverride: defaultResolution.environmentOverride,
131
+ configPath: defaultResolution.configPath,
132
+ warnings,
133
+ };
134
+ }
135
+
136
+ function buildInstructions(mode = DEFAULT_MODE, options = {}) {
137
+ const normalized = normalizeMode(mode);
138
+ if (!normalized) throw new InvalidModeError(mode);
139
+ const root = options.root ? path.resolve(options.root) : ROOT;
140
+ return fs.readFileSync(path.join(root, "core", `necktie-${normalized}.md`), "utf8")
141
+ .replace(/^\uFEFF/, "")
142
+ .replace(/\r\n?/g, "\n")
143
+ .trim();
144
+ }
145
+
146
+ function writeDefaultMode(mode, env = process.env, options = {}) {
147
+ const normalized = normalizeMode(mode);
148
+ if (!normalized) throw new InvalidModeError(mode);
149
+ const loaded = readConfig(env, options);
150
+ const config = { ...loaded.config, defaultMode: normalized };
151
+ const target = loaded.path;
152
+ fs.mkdirSync(path.dirname(target), { recursive: true });
153
+ const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
154
+ try {
155
+ fs.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
156
+ fs.renameSync(temporary, target);
157
+ } finally {
158
+ try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch (_) {}
159
+ }
160
+ return {
161
+ writtenMode: normalized,
162
+ ...resolveDefaultMode(env, options),
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ DEFAULT_MODE,
168
+ InvalidModeError,
169
+ MODES,
170
+ buildInstructions,
171
+ configPath,
172
+ normalizeMode,
173
+ readConfig,
174
+ resolveDefaultMode,
175
+ resolveMode,
176
+ writeDefaultMode,
177
+ };
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+
8
+ const { normalizeMode } = require("./necktie-policy.cjs");
9
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
10
+
11
+ function stateDirectory(options = {}) {
12
+ return path.resolve(options.stateDirectory || path.join(os.tmpdir(), "necktie", "sessions"));
13
+ }
14
+
15
+ function sessionIdentifier(input = {}, env = process.env, options = {}) {
16
+ const candidates = [
17
+ input.session_id,
18
+ input.sessionId,
19
+ input.sessionID,
20
+ input.thread_id,
21
+ input.threadId,
22
+ env.CODEX_THREAD_ID,
23
+ env.CLAUDE_SESSION_ID,
24
+ env.COPILOT_SESSION_ID,
25
+ env.QODER_SESSION_ID,
26
+ options.fallbackId,
27
+ ];
28
+ const found = candidates.find((value) => typeof value === "string" && value.trim());
29
+ return found ? found.trim() : `parent-${options.parentPid || process.ppid}`;
30
+ }
31
+
32
+ function statePath(host, sessionId, options = {}) {
33
+ const digest = crypto.createHash("sha256").update(`${host}\0${sessionId}`).digest("hex");
34
+ return path.join(stateDirectory(options), `${host}-${digest}.json`);
35
+ }
36
+
37
+ function prune(options = {}) {
38
+ const directory = stateDirectory(options);
39
+ let entries;
40
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
41
+ const now = options.now || Date.now();
42
+ for (const entry of entries) {
43
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
44
+ const target = path.join(directory, entry.name);
45
+ try {
46
+ if (now - fs.statSync(target).mtimeMs > MAX_AGE_MS) fs.unlinkSync(target);
47
+ } catch (_) {}
48
+ }
49
+ }
50
+
51
+ function readSessionMode(host, sessionId, options = {}) {
52
+ prune(options);
53
+ try {
54
+ const parsed = JSON.parse(fs.readFileSync(statePath(host, sessionId, options), "utf8"));
55
+ return typeof parsed?.mode === "string" ? parsed.mode : null;
56
+ } catch (_) {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function writeSessionMode(host, sessionId, mode, options = {}) {
62
+ const normalized = normalizeMode(mode);
63
+ 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
+ }
76
+ return normalized;
77
+ }
78
+
79
+ module.exports = {
80
+ MAX_AGE_MS,
81
+ prune,
82
+ readSessionMode,
83
+ sessionIdentifier,
84
+ stateDirectory,
85
+ statePath,
86
+ writeSessionMode,
87
+ };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@gillcash/necktie",
3
- "version": "0.2.0",
4
- "description": "The angel of late-stage capitalism for your AI agent: an always-on response check and an explicit bounded review loop.",
5
- "keywords": ["agent-plugin", "agent-loop", "prompt-reversal", "review", "verification"],
3
+ "version": "0.4.0",
4
+ "description": "The angel of late-stage capitalism for your AI agent, with Mammon as its internal adversary.",
5
+ "keywords": ["agent-plugin", "incentives", "power", "externalities", "opinionated-agent"],
6
6
  "license": "MIT",
7
7
  "author": {
8
8
  "name": "gillcash",
@@ -25,9 +25,11 @@
25
25
  "AGENTS.md",
26
26
  "plugin.json",
27
27
  "core/",
28
+ "lib/",
28
29
  "hooks/",
29
30
  "skills/",
30
31
  "commands/",
32
+ "docs/",
31
33
  ".opencode/",
32
34
  ".qoder/",
33
35
  ".qoder-plugin/",
@@ -40,8 +42,10 @@
40
42
  "!pi-extension/test/**"
41
43
  ],
42
44
  "scripts": {
43
- "build:adapters": "node scripts/build-adapters.js && node scripts/build-openclaw-skills.js",
44
- "check:adapters": "node scripts/build-adapters.js --check",
45
+ "build:policy": "node scripts/build-policy.js",
46
+ "build:adapters": "npm run build:policy && node scripts/build-adapters.js && node scripts/build-openclaw-skills.js",
47
+ "check:policy": "node scripts/build-policy.js --check",
48
+ "check:adapters": "npm run check:policy && node scripts/build-adapters.js --check && node scripts/build-openclaw-skills.js --check",
45
49
  "check:versions": "node scripts/check-versions.js",
46
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"
47
51
  },
@@ -1,11 +1,27 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import { createRequire } from "node:module";
4
2
 
5
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
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
6
 
7
- export function coreContext() {
8
- return fs.readFileSync(path.resolve(__dirname, "..", "core", "necktie-core.md"), "utf8").trim();
7
+ export function coreContext(mode = DEFAULT_MODE) {
8
+ return buildInstructions(mode);
9
+ }
10
+
11
+ export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
12
+ const fallback = normalizeMode(fallbackMode) || DEFAULT_MODE;
13
+ if (!Array.isArray(entries)) return fallback;
14
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
15
+ const entry = entries[index];
16
+ if (entry?.type !== "custom" || entry?.customType !== "necktie-mode") continue;
17
+ const mode = normalizeMode(entry?.data?.mode);
18
+ if (mode) return mode;
19
+ }
20
+ return fallback;
21
+ }
22
+
23
+ export function parseNecktieModeCommand(text) {
24
+ return parseModeArguments(text);
9
25
  }
10
26
 
11
27
  export function sendSkill(pi, skill, args, ctx) {
@@ -16,14 +32,56 @@ export function sendSkill(pi, skill, args, ctx) {
16
32
  }
17
33
 
18
34
  export default function necktieExtension(pi) {
19
- for (const skill of ["necktie", "necktie-critique", "necktie-reverse", "necktie-review"]) {
20
- pi.registerCommand(skill, {
21
- description: `Run /skill:${skill}`,
22
- handler: (args, ctx) => sendSkill(pi, skill, args, ctx),
23
- });
24
- }
35
+ let configuredDefault = resolveMode();
36
+ let currentMode = configuredDefault.mode;
37
+
38
+ pi.registerCommand("necktie", {
39
+ description: "Run /skill:necktie",
40
+ handler: (args, ctx) => sendSkill(pi, "necktie", args, ctx),
41
+ });
42
+
43
+ pi.registerCommand("necktie-mode", {
44
+ description: "Set Necktie mode: lite, full, or ultra. Commands: status, default <mode>",
45
+ handler: async (args, ctx) => {
46
+ 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
+ }
71
+ ctx?.ui?.notify?.(message, parsed.type === "invalid" ? "warning" : "info");
72
+ return message;
73
+ },
74
+ });
75
+
76
+ pi.on("session_start", async (_event, ctx) => {
77
+ configuredDefault = resolveMode();
78
+ for (const warning of configuredDefault.warnings) ctx?.ui?.notify?.(warning, "warning");
79
+ const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
80
+ currentMode = resolveSessionMode(entries, configuredDefault.mode);
81
+ });
82
+
25
83
  pi.on("before_agent_start", async (event) => {
26
84
  const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
27
- return { systemPrompt: `${base}${coreContext()}` };
85
+ return { systemPrompt: `${base}${coreContext(currentMode)}` };
28
86
  });
29
87
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gillcash/necktie-pi-extension",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "necktie",
4
- "version": "0.2.0",
5
- "description": "The angel of late-stage capitalism for your AI agent: an always-on response check and an explicit bounded review loop.",
4
+ "version": "0.4.0",
5
+ "description": "The angel of late-stage capitalism for your AI agent, with Mammon as its internal adversary.",
6
6
  "author": {
7
7
  "name": "gillcash",
8
8
  "url": "https://github.com/gillcash"
@@ -10,5 +10,5 @@
10
10
  "homepage": "https://github.com/gillcash/necktie",
11
11
  "repository": "https://github.com/gillcash/necktie",
12
12
  "license": "MIT",
13
- "keywords": ["agent-loop", "prompt-reversal", "review", "verification"]
13
+ "keywords": ["incentives", "power", "externalities", "opinionated-agent"]
14
14
  }