@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.
- package/.opencode/plugins/necktie.mjs +11 -40
- package/.qoder-plugin/plugin.json +1 -1
- package/NOTICE +1 -1
- package/README.md +3 -1
- package/docs/host-support.md +23 -0
- package/hooks/necktie-context.js +11 -47
- package/lib/necktie-command.cjs +56 -12
- package/lib/necktie-json.cjs +20 -0
- package/lib/necktie-policy.cjs +23 -61
- package/lib/necktie-session.cjs +2 -12
- package/package.json +6 -7
- package/pi-extension/index.js +11 -40
- package/pi-extension/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/necktie-research/scripts/research_prompt_loop.py +35 -275
- package/skills/necktie-research/scripts/research_state.py +152 -0
- package/core/necktie-core.md +0 -17
- package/core/necktie-full.md +0 -10
- package/core/necktie-lite.md +0 -5
- package/core/necktie-mammon.md +0 -15
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
88
|
-
commandMessages.
|
|
89
|
-
|
|
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.
|
|
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
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
|
|
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).
|
package/docs/host-support.md
CHANGED
|
@@ -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.
|
package/hooks/necktie-context.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
package/lib/necktie-command.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/necktie-policy.cjs
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
91
|
-
if (!
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
125
|
-
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
|
-
|
|
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
|
-
|
|
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),
|
package/lib/necktie-session.cjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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-
|
|
47
|
-
"build:adapters": "
|
|
48
|
-
"check:policy": "node scripts/build-
|
|
49
|
-
"check:adapters": "
|
|
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"],
|
package/pi-extension/index.js
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_MODE, buildInstructions, normalizeMode, resolveMode } from "../lib/necktie-policy.cjs";
|
|
2
|
+
import { executeModeCommand, parseModeArguments } from "../lib/necktie-command.cjs";
|
|
2
3
|
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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}${
|
|
56
|
+
return { systemPrompt: `${base}${buildInstructions(currentMode)}` };
|
|
86
57
|
});
|
|
87
58
|
}
|
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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
review
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
272
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
packet
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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)
|
package/core/necktie-core.md
DELETED
|
@@ -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.
|
package/core/necktie-full.md
DELETED
|
@@ -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.
|
package/core/necktie-lite.md
DELETED
|
@@ -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.
|
package/core/necktie-mammon.md
DELETED
|
@@ -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.
|