@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.
- package/.opencode/command/necktie-mode.md +7 -0
- package/.opencode/command/necktie.md +2 -2
- package/.opencode/plugins/necktie.mjs +67 -5
- package/.qoder/rules/necktie.md +28 -10
- package/.qoder-plugin/plugin.json +3 -3
- package/AGENTS.md +28 -10
- package/LICENSE +20 -20
- package/NOTICE +2 -2
- package/README.es.md +46 -27
- package/README.ko.md +45 -26
- package/README.md +83 -118
- package/commands/necktie-mode.toml +5 -0
- package/commands/necktie.toml +2 -2
- package/core/necktie-core.md +28 -10
- package/core/necktie-full.md +36 -0
- package/core/necktie-lite.md +28 -0
- package/core/necktie-ultra.md +42 -0
- package/docs/host-support.md +50 -0
- package/docs/process-provenance.md +48 -0
- package/docs/release-notes-0.4.0.md +14 -0
- package/hooks/copilot-hooks.json +8 -0
- package/hooks/hooks.json +13 -2
- package/hooks/necktie-context.js +126 -14
- package/lib/necktie-command.cjs +44 -0
- package/lib/necktie-policy.cjs +177 -0
- package/lib/necktie-session.cjs +87 -0
- package/package.json +9 -5
- package/pi-extension/index.js +71 -13
- package/pi-extension/package.json +1 -1
- package/plugin.json +3 -3
- package/skills/necktie/SKILL.md +11 -81
- package/skills/necktie/agents/openai.yaml +3 -3
- package/skills/necktie/references/full.md +36 -0
- package/skills/necktie/references/lite.md +28 -0
- package/skills/necktie/references/policy.md +46 -0
- package/skills/necktie/references/ultra.md +42 -0
- package/.opencode/command/necktie-critique.md +0 -5
- package/.opencode/command/necktie-reverse.md +0 -5
- package/.opencode/command/necktie-review.md +0 -5
- package/commands/necktie-critique.toml +0 -2
- package/commands/necktie-reverse.toml +0 -2
- package/commands/necktie-review.toml +0 -2
- package/skills/necktie/references/loop-protocol.md +0 -85
- package/skills/necktie/scripts/necktie_loop.py +0 -199
- package/skills/necktie-critique/SKILL.md +0 -48
- package/skills/necktie-critique/agents/openai.yaml +0 -6
- package/skills/necktie-reverse/SKILL.md +0 -41
- package/skills/necktie-reverse/agents/openai.yaml +0 -6
- package/skills/necktie-reverse/references/blueprint-template.md +0 -54
- package/skills/necktie-review/SKILL.md +0 -61
- package/skills/necktie-review/agents/openai.yaml +0 -6
- package/skills/necktie-review/references/reviewer-rubric.md +0 -29
- 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":
|
|
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":
|
|
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
|
}
|
package/hooks/necktie-context.js
CHANGED
|
@@ -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
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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.
|
|
4
|
-
"description": "The angel of late-stage capitalism for your AI agent
|
|
5
|
-
"keywords": ["agent-plugin", "
|
|
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:
|
|
44
|
-
"
|
|
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
|
},
|
package/pi-extension/index.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
import
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
1
|
+
import { createRequire } from "node:module";
|
|
4
2
|
|
|
5
|
-
const
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|
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.
|
|
5
|
-
"description": "The angel of late-stage capitalism for your AI agent
|
|
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": ["
|
|
13
|
+
"keywords": ["incentives", "power", "externalities", "opinionated-agent"]
|
|
14
14
|
}
|