From 566d36f57cc9c4281ff6146e7c21ccaed1034cd1 Mon Sep 17 00:00:00 2001 From: YANG Jingyu Date: Fri, 11 Sep 2026 03:30:50 +0800 Subject: [PATCH 1/5] feat: add workspace philosophy management --- .editorconfig | 12 + .gitattributes | 17 ++ .gitignore | 34 +++ .gitmodules | 3 + AGENTS.md | 13 + OrganonCore | 1 + PHILOSOPHY.md | 1 + README.md | 59 +++- docs/self-iteration.md | 85 ++++++ package.json | 9 + scripts/apply.js | 10 + scripts/check.js | 7 + scripts/classify.js | 7 + scripts/export.js | 7 + scripts/init.js | 7 + scripts/lib/cli.js | 23 ++ scripts/lib/frontmatter.js | 96 ++++++ scripts/lib/io.js | 146 +++++++++ scripts/lib/lock.js | 57 ++++ scripts/lib/operations.js | 229 ++++++++++++++ scripts/lib/sections.js | 64 ++++ scripts/resolve.js | 7 + skills/organon-absorb/SKILL.md | 11 + skills/organon-assess/SKILL.md | 11 + skills/organon-philosophy/SKILL.md | 92 ++++++ skills/organon-principled-review/SKILL.md | 11 + skills/organon-wording-review/SKILL.md | 8 + tests/management.test.js | 355 ++++++++++++++++++++++ zh/AGENTS.md | 15 + zh/README.md | 58 ++++ zh/docs/self-iteration.md | 85 ++++++ 31 files changed, 1539 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 AGENTS.md create mode 160000 OrganonCore create mode 120000 PHILOSOPHY.md create mode 100644 docs/self-iteration.md create mode 100644 package.json create mode 100644 scripts/apply.js create mode 100644 scripts/check.js create mode 100644 scripts/classify.js create mode 100644 scripts/export.js create mode 100644 scripts/init.js create mode 100644 scripts/lib/cli.js create mode 100644 scripts/lib/frontmatter.js create mode 100644 scripts/lib/io.js create mode 100644 scripts/lib/lock.js create mode 100644 scripts/lib/operations.js create mode 100644 scripts/lib/sections.js create mode 100644 scripts/resolve.js create mode 100644 skills/organon-absorb/SKILL.md create mode 100644 skills/organon-assess/SKILL.md create mode 100644 skills/organon-philosophy/SKILL.md create mode 100644 skills/organon-principled-review/SKILL.md create mode 100644 skills/organon-wording-review/SKILL.md create mode 100644 tests/management.test.js create mode 100644 zh/AGENTS.md create mode 100644 zh/README.md create mode 100644 zh/docs/self-iteration.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b3be88b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Normalize line endings: LF in the repository, LF on checkout. +* text=auto eol=lf + +# Sources that must stay byte-stable (content hashes depend on them) +*.md text eol=lf +*.jsonl text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.yml text eol=lf + +# Generated artifacts: hide from diffs and language stats +PHILOSOPHY.lock.json linguist-generated=true + +# Binary assets +*.png binary +*.jpg binary +*.gif binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8bfc39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Private goal workspaces (never committed) +.local/ + +# Agent / tool state +.codegraph/ +.claude/settings.local.json +.cursor/ +.copilot/ +*.log + +# Build outputs +target/ +dist/ +build/ +node_modules/ +__pycache__/ +*.pyc +.venv/ + +# Secrets / env +.env +.env.* +!.env.example + +# OS / editor noise +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +!.vscode/settings.json +!.vscode/extensions.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..eb54b4b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "OrganonCore"] + path = OrganonCore + url = https://github.com/shendeguize/OrganonCore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b998c6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# Working on AgentOrganon + +Read [OrganonCore/AGENTS.md](OrganonCore/AGENTS.md) together with this file, including its linked philosophy, review methods, and [iteration rules](OrganonCore/skills/references/iteration.md). Apply the shared maintenance, independent review, wording, authorization, record, and stopping rules to outer repository work. Core's text-only delivery constraint applies to the Core repository; the outer repository deliberately supplies zero-dependency Node scripts. + +Keep the adopted workspace philosophy, a proposed replacement, and the Core method's constraints distinct. Use the [philosophy resolution contract](OrganonCore/skills/references/philosophy-resolution.md) and [format contract](OrganonCore/skills/references/structure.md). Outer skills pass the selected baseline through delegation. A difference from Core is not automatically a contradiction within an adopter's commitments. Compatibility alone does not warrant adoption, and conflict alone does not defeat revision reasons. + +The root `PHILOSOPHY.md` is a read-only symlink to the Core source, with no root derived lock. Resolve its relative references from the real source directory. Do not use philosophy management writes to replace it or reach the Core target by another path. Manage regular adopter copies; modify Core only through its applicable maintenance or absorption workflow and authorization. + +Scripts perform deterministic parsing, comparison, and file operations. Agents and users retain philosophical judgments, semantic version decisions, and specific adoption authorization. A prepared application plan binds inputs; it does not certify authorization or philosophical correctness. Run relevant mechanical tests and independent actual skill cases for behavior changes, preserving raw independent responses before comparison with expected boundaries. + +Keep changes within the authorized scope and preserve existing user work. Retain private evidence and the shared six-part records under ignored `.local/`; use `.local/iterations/merges/` for merge reports, candidates, decisions, plans, and records. Verify that the directory is ignored before writing records. English documentation is authoritative for its translations; follow [zh/AGENTS.md](zh/AGENTS.md) when maintaining the translation pair. Do not translate SKILL.md files as part of this implementation. + +Remote publication follows [Core's authorization rules](OrganonCore/AGENTS.md#remote-publication-authorization). Reuse authorization for its stated action, destination, and scope. An implementation request does not itself authorize a push. Do not publish an outer revision that references an unavailable Core commit. diff --git a/OrganonCore b/OrganonCore new file mode 160000 index 0000000..ff9baa0 --- /dev/null +++ b/OrganonCore @@ -0,0 +1 @@ +Subproject commit ff9baa0adaf3bcb99e055bf8f2d567ed8de9244c diff --git a/PHILOSOPHY.md b/PHILOSOPHY.md new file mode 120000 index 0000000..223baf6 --- /dev/null +++ b/PHILOSOPHY.md @@ -0,0 +1 @@ +OrganonCore/PHILOSOPHY.md \ No newline at end of file diff --git a/README.md b/README.md index edfdc56..dc1a39b 100644 --- a/README.md +++ b/README.md @@ -1 +1,58 @@ -# AgentOrganon \ No newline at end of file +# AgentOrganon + +AgentOrganon supplies workspace skills and deterministic file operations around [OrganonCore](OrganonCore/README.md). Core contains the philosophy and review methods; this outer repository contains the Node scripts that manage adopted copies. The adopting workspace may revise or withdraw commitments with an explicit user decision. + +English is the maintained source for this repository's documentation. See the [Chinese translation](zh/README.md). + +## Skills + +| Skill | Responsibility | +| --- | --- | +| [organon-assess](skills/organon-assess/SKILL.md) | Assess an input against the selected workspace philosophy. | +| [organon-absorb](skills/organon-absorb/SKILL.md) | Examine reasons for revising that philosophy and implement authorized adoption after review. | +| [organon-principled-review](skills/organon-principled-review/SKILL.md) | Delegate full analysis under the Core method's stated triggers and standards. | +| [organon-wording-review](skills/organon-wording-review/SKILL.md) | Review wording; no philosophy file is required. | +| [organon-philosophy](skills/organon-philosophy/SKILL.md) | Initialize, check, export, import, and merge adopted copies. | + +The first three skills use an explicitly supplied philosophy path, or look for `PHILOSOPHY.md` first at the calling workspace's Git root and then in its current directory. They do not search descendants. An invalid explicit path stops the operation; missing default candidates prompt initialization. They never fall back to the bundled Core philosophy. The selected path remains the assessment baseline through delegation; proposed revisions and Core method constraints are identified separately. + +Compatibility does not establish a reason for adoption, and conflict with an existing commitment does not by itself defeat reasons to revise it. Scripts check and transform files. Agents and users assess meaning, review the candidate as a whole, and decide adoption. + +## Files and versions + +An adopting workspace uses a regular `PHILOSOPHY.md` and adjacent `PHILOSOPHY.lock.json`, whose `document_filename` identifies the managed file in that directory. The [format contract](OrganonCore/skills/references/structure.md) defines the four supported frontmatter fields and stable IDs for every heading with its direct body and for introductory text. It supports unindented ATX headings such as `## Title`; other ATX forms and Setext headings are rejected. The current three-chapter Core organization is an initialization template; adopters may reorganize it. Initialization adds an empty Extensions section, identified by stable ID `extensions` rather than its title or position. + +- `format_version` identifies the supported file format. Unsupported formats stop managed writes. +- `philosophy_version` describes semantic revision, proposed by an agent and decided by the user: changed or withdrawn commitments, meanings, or applicability require major; additions preserving existing commitments require minor; meaning-preserving wording or structural maintenance requires patch. +- `core_version` records the last fully reviewed Core source version. A version outside `package.json`'s `organon.coreVersion` range produces a source-version warning, not a philosophical verdict. +- `derived_from` records export provenance. Neither provenance nor matching versions establishes a common ancestor. + +The lock stores hashes and structure for reviewed source checkpoints, plus remembered declines. It contains no old source text. Local differences from a checkpoint are expected. A declined source unit is not proposed again until its incoming state changes; the actual difference remains visible. Imports with no verifiable source checkpoint use two-way differences and never advance Core's reviewed version. + +This repository's root `PHILOSOPHY.md` is a relative symlink to `OrganonCore/PHILOSOPHY.md`, used only for reading; relative references resolve from the real source directory. There is no root derived lock. Management scripts reject writes through or over this symlink and writes to the Core source. Core changes use its explicit maintenance or absorption workflow. + +## Local use + +Use Node.js 22; there are no third-party runtime dependencies. Run these commands from this checkout, with the OrganonCore submodule present. Use absolute script paths when working from another directory. The target's parent directory must already exist. In a Git workspace, ignore `.local/` before initialization so recovery journals remain private; the scripts create the journal directory when needed. + +```sh +node scripts/check.js --source OrganonCore/PHILOSOPHY.md +node scripts/check.js --source PHILOSOPHY.md +node scripts/init.js --target /path/to/workspace/PHILOSOPHY.md +node scripts/check.js /path/to/workspace/PHILOSOPHY.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/export.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/core-part.md --core-only +node --test +``` + +Initialization and export require absent output paths. `--core-only` removes the subtree identified by `extensions`; a missing marker stops export. The remaining text is still the adopter's text, not an official Core copy. Source identities are caller-supplied labels or stored document identities, not authenticated publisher identities. Managed operations, including reads for initialization, export, and classification, stop when an input has a pending transaction; use the reported recovery command before resuming. + +Use `organon-philosophy merge --dry-run` to request a read-only difference report. The skill invokes `classify.js`; there is no installed `organon-philosophy` executable. The skill documents candidate preparation, decisions, recovery, and application commands. Keep reports, candidates, plans, and six-part merge records in ignored `.local/iterations/merges/`. A prepared plan binds the reviewed inputs and candidate; stale inputs stop application. Preparation and successful file checks do not supply adoption authorization. + +The private package name is `@shendeguize/agent-organon`, version `0.1.0`. Installation modes and release publication are outside this implementation. Read [AGENTS.md](AGENTS.md) before contributing; remote publication follows the referenced Core authorization rules. + +Use the [self-practice and iteration protocol](docs/self-iteration.md) to compare methods, retain evidence, and complete a bounded iteration. + +## License + +MIT. diff --git a/docs/self-iteration.md b/docs/self-iteration.md new file mode 100644 index 0000000..7fc9966 --- /dev/null +++ b/docs/self-iteration.md @@ -0,0 +1,85 @@ +# Self-practice and bounded iteration + +Use this protocol to practice AgentOrganon's five outer skills and their delegated Core methods on this repository, compare alternatives, and complete an evidence-based iteration. Judgment quality has priority; justified stability is a valid outcome. Follow [AGENTS.md](../AGENTS.md) and the [shared iteration rules](../OrganonCore/skills/references/iteration.md). This protocol is a revisable method, not an additional philosophical commitment or proof of the philosophy. + +## Freeze the object, philosophy, criteria, and methods + +Preserve both repositories' complete current worktrees, including uncommitted changes, original requirements, and relevant prior evidence. A complete snapshot is a preservation boundary, not an input directory automatically open to every role. Record each role's supplied materials and permitted source readback separately; audit the corresponding input and protection bindings within the auditor's stated access scope. Do not extend access or an audit to unrelated historical material merely because it shares a saved directory. Record the environment and actual instructions available to agents. Use distinct experimental identifiers: + +- **R0:** the repository and task materials being assessed. Keep this object fixed during method comparisons. +- **F0 / C0:** the adopted philosophy and the criteria for this iteration. State applicability, assumptions, terms, expected observable outcomes, and important regressions before candidate implementation. +- **M0 / M1…:** the current method and candidate method versions. Supply the chosen method separately from the fixed R0 object, including when the method's own text is the object of review. + +These identifiers belong to the experiment record; they add no philosophy-format fields. Keep Core text-only and outer Node scripts free of third-party dependencies. Skill organization, responsibilities, shared rules, and delegation may be challenged. A proposed philosophical revision remains a candidate until the user decides its concrete meaning. + +## Define the scope before implementation + +Choose either the full-coverage study below or a targeted stage addressing a specific question. Before execution, record the question, included cases and reasoned exclusions, necessary regressions, method conditions, logical-task and execution-call counts, and stopping conditions. Use authorization that covers this scope; refer unresolved material scope choices to the user rather than silently expanding the work. + +A targeted stage retains the applicable comparison, repetition, adoption, and overall revision requirements below. Report its completion separately from coverage of the full study, identifying unperformed cases. Completing a stage does not authorize the remaining cases or make the full study complete. + +### Full-coverage case set: 17 cases + +Each case card states its raw request, common materials, applicable grounds, observable results, important regression conditions, and authorized actions. Separate an execution view containing the raw task with its stated goals and constraints, shared facts and source materials, and operational authorization from a judge's view containing expected conclusions, answers about applicable provisions, and failure criteria. Give execution agents only the execution view. Mark a real repository task separately from a constructed situation. Cover three cases for each skill: + +| Skill | Real repository task | Boundary or no-change case | Independently held-out variation | +| --- | --- | --- | --- | +| assess | Assess the current design and grounds for its capability claims. | Distinguish justified stability, insufficient support, and philosophical conflict. | Change adopter commitments or applicability conditions. | +| principled-review | Examine responsibilities and alternative architectures. | Examine lost conditions in simplification and support for comparative claims. | Examine a new structural problem not used in candidate design. | +| absorb | Decide whether practice findings warrant philosophical revision. | Give grounded reasons not to absorb already covered content. | Examine a complete revision candidate challenging an existing commitment in an isolated copy. | +| wording-review | Review actual README, skill, or protocol text. | Preserve accurate wording and distinguish defects from preferences. | Examine unfamiliar referents, ambiguity, and qualifications. | +| philosophy | Actually initialize, check, export, import, and apply a merge in an adopted copy. | Exercise decline memory, an unknown source baseline, and non-adoption. | Exercise stale inputs or interrupted-write recovery and inspect document and lock state. | + +Add two collaboration cases: a real issue carried through assessment, any necessary revision, and verification without prescribing a skill sequence; and criticism of this protocol and C0, preserving earlier judgments when criteria change. + +An agent uninvolved in candidate writing creates and seals the held-out cases. Once revealed, a case is no longer an unknown variation. Run all management writes in explicitly derived, isolated regular copies; leave the real root philosophy symlink unchanged. Experimental authorization applies only to the specified copy. It does not authorize actual Core adoption. A grounded no-absorption result can complete the real absorb case. + +## Screen architectures, then compare actual runs + +For the full-coverage study, compare the current design with at least four alternatives: unified routing with specialized internal methods; partial combination of assess and full-review responsibilities; direct Core workspace parameters with less outer duplication; and explicit handoff materials and workflow state. State each design's objectives, responsibilities, handoffs, added state, expected contribution, and failure risks. A targeted stage identifies the relevant alternatives and gives reasons for exclusions. + +Use actual problems and confirmed constraints to select at most two challengers with different main hypotheses. Record reasons for exclusions; selecting none is allowed. Refer unresolved material selection trade-offs to the user. Static screening does not establish behavioral improvement. + +Run every included case under a generic task prompt, M0, and each selected candidate. The generic control receives the same philosophy, facts, and tools but does not explicitly invoke the Organon workflow. When SKILL.md is the review object, every group may read it as evidence. The comparison therefore concerns the benefit of explicitly invoking the workflow, not ignorance of its text. + +Keep the model, reasoning configuration, inputs, and tool conditions the same. Independently run each case under each method three times and rotate group order. For full coverage, two challengers produce an initial comparison of **17 × 4 × 3 = 204 task runs**, excluding review and revision retests. Derive a targeted stage's counts from its confirmed cases and stage structure; distinguish logical tasks from execution calls. Each case × method version × repetition requires a fresh context and separate copy, or a verifiably equivalent restoration. + +Inspect automatic instruction loading and expected-answer leakage. Preserve the actual loaded materials. If a comparison cannot isolate the intended difference, label it unidentifiable and make no gain claim from it. Execution agents must not receive expected conclusions. Save every raw response, tool trace, failure, and before/after file state before comparison; do not select only a group's best response. + +Independent reviewers first judge the raw task and grounds, then compare outputs with method-version labels hidden, and only afterward receive the author's rationale. They report grounds and disagreements rather than voting. Exposure learned during comparison must not be represented as an independent first judgment. + +## Judge consequences and limit revisions + +C0 evaluates task consequences and necessary behavior, not existing skill names, chapter counts, or delegation order. Examine applicability, support for claim strength, consistency conditions, counterexamples and unknowns, responsibilities and authorization, and whether a recommendation addresses the actual problem. Reading volume, elapsed time, and delegation counts are secondary evidence; do not combine them into a philosophical-quality score. + +A candidate may enter the adoption decision only with a concrete improvement in judgment, supporting grounds, and no untreated important regression. Predefined important regressions include changing the baseline without acknowledgment, fabricating grounds, unauthorized adoption, omitting conditions that could change the conclusion, and breaking necessary file protections. One supported important failure must be addressed; two successful repetitions do not cancel it. The user resolves other material quality trade-offs, concrete philosophical changes, and major review disagreements that the available grounds do not settle. + +Connect every revision to a specific problem, causal hypothesis, and expected improvement. Change the narrowest responsible layer. Retest affected comparisons and necessary regressions; method changes require actual behavior and a new independently prepared variation. Each affected comparison still needs three valid runs per method. Reuse prior outputs only when inputs, R0, F0, C0, method version, and model and tool configuration are unchanged and verifiable; a single run or a result from a different configuration cannot complete that requirement. After initial testing, this iteration permits at most **two overall rounds of candidate revision**, not two per case or candidate. Continue only with new grounds. Stop when the problem is resolved, no new grounds remain, or the limit is reached; retain unresolved failures at the limit. + +If F1 or C1 is approved, preserve the old judgment and its conditions. Stop mixed comparisons that no longer share an applicable baseline or criterion. Define any new experiment's scope explicitly. Renaming versions or restarting an experiment does not reset this iteration's revision limit; an extension requires a new user decision. New criteria must not retroactively turn an old failure into a pass. + +## Adopt, check the actual repository, and report limits + +Keep the [six-part record](../OrganonCore/skills/references/iteration.md#six-part-record), snapshots, case cards, raw runs, comparisons, and user decisions under ignored `.local/iterations/`. Use new output locations for each stage rather than continuing a sealed record in place. Save later supplements and corrections separately and link them to the original record. If a sealed artifact is changed accidentally, preserve the changed version and the restoration history as well as the original; matching final hashes do not establish that no intervening change occurred. + +Before each delivery, check the corresponding evidence: + +| Delivery | Completion evidence and self-check | +| --- | --- | +| Baseline and protocol | Complete worktree snapshots, environment, F0/C0, the confirmed scope, and its case cards (17 for full coverage); distinguish object, method, and standards. | +| Current practice and screening | Actual current-case outcomes and inclusion/exclusion reasons; do not force modification to exercise a skill. | +| Candidate comparison | All raw evidence, independent first judgments, and case comparisons; inspect material equality, contamination, and counterexamples. | +| Minimal revision | Each change's problem and hypothesis, affected retests, and necessary regression results. | +| Adoption and closure | Actual changes match the reviewed candidate and authorization; record adoption, retention, rejection, or unresolved status for each decision, with stage completion and full-study coverage reported separately. | + +Use existing CLI and agent orchestration; do not prebuild an evaluation platform. Review changed English wording and check translated meaning separately. Run relevant mechanical checks from the repository root: + +```sh +rtk proxy node --test +rtk proxy node scripts/check.js --source PHILOSOPHY.md +rtk proxy node scripts/check.js --source OrganonCore/PHILOSOPHY.md +``` + +Once reviewed and authorized adoption produces **R1**, perform self-application on the actual resulting repository. Record that result separately from the fixed-R0 method comparison. Assess the output, method, and criteria through one bounded self-check and independent cross-review, without creating a recursive review chain. + +Deliver this maintained protocol, private evidence, any validated and authorized minimal changes, and a report separating mechanical checks, observed behavior, concrete improvements, unresolved questions, and support limits. Remote publication is outside this protocol's authorization. Conclusions concern this repository, tested tasks, and recorded model configuration. Three repetitions expose variation; they do not prove universal effectiveness or philosophical correctness. diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a464a0 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "@shendeguize/agent-organon", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { "test": "node --test" }, + "organon": { "coreVersion": ">=0.1.0 <0.2.0" } +} diff --git a/scripts/apply.js b/scripts/apply.js new file mode 100644 index 0000000..31b2e6e --- /dev/null +++ b/scripts/apply.js @@ -0,0 +1,10 @@ +import { argumentsFor, run } from './lib/cli.js'; +import { prepare, applyPlan } from './lib/operations.js'; +import { recover } from './lib/io.js'; +run(() => { + const args = argumentsFor(['prepare', 'report', 'candidate', 'decisions', 'record', 'plan', 'recover'], ['prepare']); + if (args.positional.length || [args.prepare, args.plan, args.recover].filter(Boolean).length !== 1) throw new Error('Select exactly one of --prepare, --plan or --recover'); + if (args.prepare) return prepare(args); + if (args.plan) return applyPlan(args.plan); + return recover(args.recover); +}); diff --git a/scripts/check.js b/scripts/check.js new file mode 100644 index 0000000..3cd9512 --- /dev/null +++ b/scripts/check.js @@ -0,0 +1,7 @@ +import { argumentsFor, run } from './lib/cli.js'; +import { check } from './lib/operations.js'; +run(() => { + const args = argumentsFor(['source'], ['source']); + if (args.positional.length !== 1) throw new Error('Usage: node scripts/check.js [--source] FILE'); + return check(args.positional[0], { source: args.source }); +}); diff --git a/scripts/classify.js b/scripts/classify.js new file mode 100644 index 0000000..b4e2a3b --- /dev/null +++ b/scripts/classify.js @@ -0,0 +1,7 @@ +import { argumentsFor, run } from './lib/cli.js'; +import { classify } from './lib/operations.js'; +run(() => { + const args = argumentsFor(['ours', 'theirs', 'source-id', 'kind', 'base-file']); + if (args.positional.length) throw new Error('Unexpected positional arguments'); + return classify({ ...args, sourceId: args['source-id'], baseFile: args['base-file'] }); +}); diff --git a/scripts/export.js b/scripts/export.js new file mode 100644 index 0000000..2c20a0f --- /dev/null +++ b/scripts/export.js @@ -0,0 +1,7 @@ +import { argumentsFor, run } from './lib/cli.js'; +import { exportPhilosophy } from './lib/operations.js'; +run(() => { + const args = argumentsFor(['source', 'out', 'source-id', 'core-only'], ['core-only']); + if (args.positional.length) throw new Error('Unexpected positional arguments'); + return exportPhilosophy({ ...args, sourceId: args['source-id'], coreOnly: args['core-only'] }); +}); diff --git a/scripts/init.js b/scripts/init.js new file mode 100644 index 0000000..32f4271 --- /dev/null +++ b/scripts/init.js @@ -0,0 +1,7 @@ +import { argumentsFor, run } from './lib/cli.js'; +import { initialize } from './lib/operations.js'; +run(() => { + const args = argumentsFor(['target', 'source', 'source-id']); + if (args.positional.length) throw new Error('Unexpected positional arguments'); + return initialize({ ...args, sourceId: args['source-id'] }); +}); diff --git a/scripts/lib/cli.js b/scripts/lib/cli.js new file mode 100644 index 0000000..a75d11b --- /dev/null +++ b/scripts/lib/cli.js @@ -0,0 +1,23 @@ +import { serialize } from './io.js'; + +export function argumentsFor(allowed, booleans = []) { + const result = { positional: [] }; + const args = process.argv.slice(2); + for (let index = 0; index < args.length; index++) { + const value = args[index]; + if (!value.startsWith('--')) { result.positional.push(value); continue; } + const key = value.slice(2); + if (!allowed.includes(key) || Object.hasOwn(result, key)) throw new Error(`Unknown or repeated option: ${value}`); + if (booleans.includes(key)) result[key] = true; + else { + if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`Missing value for ${value}`); + result[key] = args[++index]; + } + } + return result; +} + +export function run(action) { + try { process.stdout.write(serialize(action())); } + catch (error) { process.stderr.write(`${serialize({ error: error.message })}`); process.exitCode = 1; } +} diff --git a/scripts/lib/frontmatter.js b/scripts/lib/frontmatter.js new file mode 100644 index 0000000..ab1255b --- /dev/null +++ b/scripts/lib/frontmatter.js @@ -0,0 +1,96 @@ +export const FORMAT_VERSION = '0.1.0'; +export const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +export const HASH = /^[a-f0-9]{64}$/; + +export function normalize(text) { + return text.replace(/\r\n?/g, '\n'); +} + +function scalar(value) { + if (value.startsWith('"')) { + let parsed; + try { parsed = JSON.parse(value); } catch { throw new Error('Invalid quoted frontmatter scalar'); } + if (typeof parsed !== 'string') throw new Error('Expected a string scalar'); + return parsed; + } + if (SEMVER.test(value)) return value; + throw new Error('Unsupported frontmatter syntax: use X.Y.Z or a JSON double-quoted string'); +} + +export function validateMetadata(meta, supported = true) { + for (const key of ['format_version', 'philosophy_version', 'core_version']) { + if (!SEMVER.test(meta[key] ?? '')) throw new Error(`Invalid ${key}: expected X.Y.Z`); + } + if (supported && meta.format_version !== FORMAT_VERSION) { + throw new Error(`Unsupported format_version ${meta.format_version}; supported: ${FORMAT_VERSION}`); + } + if (meta.derived_from !== null) { + const source = meta.derived_from; + if (!source || Object.keys(source).sort().join(',') !== 'content_hash,philosophy_version,source_id' + || typeof source.source_id !== 'string' || !source.source_id.trim() + || !SEMVER.test(source.philosophy_version ?? '') || !HASH.test(source.content_hash ?? '')) { + throw new Error('Invalid derived_from: expected source_id, philosophy_version and content_hash'); + } + } + return meta; +} + +export function parseFrontmatter(raw, { supported = true } = {}) { + const text = normalize(raw); + if (!text.startsWith('---\n')) throw new Error('Missing philosophy frontmatter'); + const end = text.indexOf('\n---\n', 4); + if (end < 0) throw new Error('Unclosed philosophy frontmatter'); + const lines = text.slice(4, end).split('\n'); + const meta = {}; + for (let index = 0; index < lines.length; index++) { + const match = /^(format_version|philosophy_version|core_version|derived_from):(?: (.*))?$/.exec(lines[index]); + if (!match) throw new Error(`Unsupported frontmatter field or syntax: ${lines[index]}`); + const [, key, value] = match; + if (Object.hasOwn(meta, key)) throw new Error(`Duplicate frontmatter field: ${key}`); + if (key !== 'derived_from') { + if (!SEMVER.test(value ?? '')) throw new Error(`Invalid ${key}: use an unquoted X.Y.Z triple`); + meta[key] = value; + } else if (value === 'null') { + meta[key] = null; + } else if (value === undefined || value === '') { + const source = {}; + while (index + 1 < lines.length && lines[index + 1].startsWith(' ')) { + const child = /^ (source_id|philosophy_version|content_hash): (.+)$/.exec(lines[++index]); + if (!child || Object.hasOwn(source, child[1])) throw new Error('Unsupported or duplicate derived_from field'); + if (!child[2].startsWith('"')) throw new Error('derived_from values must be JSON double-quoted strings'); + source[child[1]] = scalar(child[2]); + } + meta[key] = source; + } else throw new Error('derived_from must be null or an indented source object'); + } + if (Object.keys(meta).length !== 4) throw new Error('Expected exactly four philosophy frontmatter fields'); + validateMetadata(meta, supported); + return { metadata: meta, body: text.slice(end + 5) }; +} + +export function renderFrontmatter(metadata, body) { + validateMetadata(metadata); + let header = ['---', ...['format_version', 'philosophy_version', 'core_version'].map(key => `${key}: ${metadata[key]}`)]; + if (metadata.derived_from === null) header.push('derived_from: null'); + else { + header.push('derived_from:'); + for (const key of ['source_id', 'philosophy_version', 'content_hash']) { + header.push(` ${key}: ${JSON.stringify(metadata.derived_from[key])}`); + } + } + return `${header.join('\n')}\n---\n${body}`; +} + +export function coreWarnings(version, range = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).organon.coreVersion) { + const bounds = /^>=(\d+\.\d+\.\d+) <(\d+\.\d+\.\d+)$/.exec(range); + if (!bounds || !SEMVER.test(bounds[1]) || !SEMVER.test(bounds[2])) throw new Error('Unsupported organon.coreVersion range; use >=X.Y.Z { + const x = a.split('.').map(BigInt), y = b.split('.').map(BigInt); + for (let index = 0; index < 3; index++) if (x[index] !== y[index]) return x[index] < y[index] ? -1 : 1; + return 0; + }; + if (!SEMVER.test(version) || compare(bounds[1], bounds[2]) >= 0) throw new Error('Invalid Core version or range'); + return compare(version, bounds[1]) >= 0 && compare(version, bounds[2]) < 0 + ? [] : [`Reviewed Core version ${version} is outside ${range}; this is a source-version warning, not a philosophical judgment.`]; +} +import fs from 'node:fs'; diff --git a/scripts/lib/io.js b/scripts/lib/io.js new file mode 100644 index 0000000..8b1677a --- /dev/null +++ b/scripts/lib/io.js @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { hash } from './sections.js'; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const CORE_FILE = path.join(ROOT, 'OrganonCore/PHILOSOPHY.md'); +export const CORE_ID = 'https://github.com/shendeguize/OrganonCore'; +export const lockPath = file => path.join(path.dirname(path.resolve(file)), 'PHILOSOPHY.lock.json'); +export const read = file => fs.readFileSync(file, 'utf8'); +export const json = file => JSON.parse(read(file)); +export const serialize = value => `${JSON.stringify(value, null, 2)}\n`; +export const digestFile = file => hash(fs.readFileSync(file)); +const inside = (file, dir) => file === dir || file.startsWith(`${dir}${path.sep}`); +export function canonicalTarget(file) { + file = path.resolve(file); + return path.join(fs.realpathSync(path.dirname(file)), path.basename(file)); +} +export function stat(file) { + try { return fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} + +export function assertWritable(file, { absent = false } = {}) { + file = path.resolve(file); + const info = stat(file); + if (info?.isSymbolicLink()) throw new Error(`Refusing to write through or replace symlink: ${file}`); + if (info && !info.isFile()) throw new Error(`Expected regular output file: ${file}`); + const parent = fs.realpathSync(path.dirname(file)); + const target = path.join(parent, path.basename(file)); + const core = fs.realpathSync(path.join(ROOT, 'OrganonCore')); + if (inside(target, core)) throw new Error('Core is a protected source; use its maintenance or absorb workflow'); + if (absent && info) throw new Error(`Output already exists: ${file}`); + return target; +} + +export function writeNew(file, text) { + assertWritable(file, { absent: true }); + const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600); + try { fs.writeFileSync(descriptor, text); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } +} + +export function workspaceRoot(file) { + const cwd = path.dirname(path.resolve(file)); + try { return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } + catch { return cwd; } +} + +export function privateDirectory(file, { requireIgnored = true } = {}) { + const root = workspaceRoot(file); + if (requireIgnored) try { + execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { stdio: 'ignore' }); + try { execFileSync('git', ['-C', root, 'check-ignore', '-q', '.local/'], { stdio: 'ignore' }); } + catch { throw new Error('Ignore .local/ in the workspace before saving private merge records'); } + } catch (error) { if (error.message.startsWith('Ignore ')) throw error; } + let directory = fs.realpathSync(root); + for (const segment of ['.local', 'iterations', 'merges']) { + directory = path.join(directory, segment); + if (stat(directory)?.isSymbolicLink()) throw new Error('Private record directory must not be a symlink'); + if (stat(directory) && !stat(directory).isDirectory()) throw new Error('Private record path is not a directory'); + } + return directory; +} + +export function assertPrivateRecord(target, record) { + const directory = privateDirectory(target); + const real = fs.realpathSync(record); + if (!inside(real, directory) || !fs.statSync(real).isFile()) throw new Error('Merge record must be a file under ignored .local/iterations/merges/'); + return real; +} + +export function pendingPath(file) { + file = canonicalTarget(file); + return path.join(privateDirectory(file, { requireIgnored: false }), `.pending-${hash(file).slice(0, 20)}.json`); +} + +export function assertNoPending(file) { + const pending = pendingPath(file); + if (stat(pending)) throw new Error(`Interrupted transaction: run node scripts/apply.js --recover ${path.resolve(file)}`); +} + +function replace(file, content) { + assertWritable(file); + const temporary = path.join(path.dirname(file), `.organon-${randomUUID()}.tmp`); + try { + writeNew(temporary, content); + if (stat(file)) fs.chmodSync(temporary, fs.statSync(file).mode & 0o777); + assertWritable(file); + fs.renameSync(temporary, file); + } finally { if (stat(temporary)) fs.unlinkSync(temporary); } +} + +export function recover(file) { + assertWritable(file); + file = canonicalTarget(file); + const pending = pendingPath(file); + if (!stat(pending)) throw new Error('No interrupted transaction for this target'); + const journal = json(pending); + if (journal.target !== file || journal.lock !== lockPath(file)) throw new Error('Invalid transaction target'); + for (const [target, oldText, newText] of [[file, journal.oldDocument, journal.newDocument], [journal.lock, journal.oldLock, journal.newLock]]) { + assertWritable(target); + const current = stat(target) ? read(target) : null; + if (current !== oldText && current !== newText) throw new Error(`Recovery stopped: ${target} changed outside the transaction`); + } + for (const [target, oldText] of [[file, journal.oldDocument], [journal.lock, journal.oldLock]]) { + if (oldText === null) { if (stat(target)) fs.unlinkSync(target); } + else replace(target, oldText); + } + fs.unlinkSync(pending); + return { recovered: file }; +} + +// failAfterFirst is a test seam, never a CLI option or environment override. +export function writePair(file, documentText, lockText, { create = false, failAfterFirst = false, verifyInputs = () => {} } = {}) { + assertWritable(file, { absent: create }); + file = canonicalTarget(file); + const sidecar = lockPath(file); + assertWritable(file, { absent: create }); + assertWritable(sidecar, { absent: create }); + assertNoPending(file); + privateDirectory(file); + const pending = pendingPath(file); + fs.mkdirSync(path.dirname(pending), { recursive: true, mode: 0o700 }); + const journal = { target: file, lock: sidecar, + oldDocument: stat(file) ? read(file) : null, oldLock: stat(sidecar) ? read(sidecar) : null, + newDocument: documentText, newLock: lockText }; + writeNew(pending, serialize(journal)); + let started = false; + try { + verifyInputs(); + for (const [target, expected] of [[file, journal.oldDocument], [sidecar, journal.oldLock]]) { + assertWritable(target, { absent: create }); + if ((stat(target) ? read(target) : null) !== expected) throw new Error('Inputs changed while acquiring the transaction; reclassify'); + } + started = true; + if (create) writeNew(file, documentText); else replace(file, documentText); + if (failAfterFirst) throw new Error('Injected write failure'); + if (create) writeNew(sidecar, lockText); else replace(sidecar, lockText); + fs.unlinkSync(pending); + } catch (error) { + if (!started) { fs.unlinkSync(pending); throw error; } + try { recover(file); } catch (recovery) { throw new Error(`${error.message}; recovery pending: ${recovery.message}`); } + throw error; + } +} diff --git a/scripts/lib/lock.js b/scripts/lib/lock.js new file mode 100644 index 0000000..5a62ae9 --- /dev/null +++ b/scripts/lib/lock.js @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto'; +import { FORMAT_VERSION, HASH, SEMVER } from './frontmatter.js'; +import { ID, hash } from './sections.js'; +import { CORE_ID } from './io.js'; + +export function checkpoint(document, kind) { + return { kind, format_version: document.metadata.format_version, + philosophy_version: document.metadata.philosophy_version, core_version: document.metadata.core_version, + content_hash: document.content_hash, + sections: Object.fromEntries(document.sections.map(section => [section.id, section.hash])), + structure: document.structure, structure_hash: document.structure_hash }; +} + +export function initialLock(document, sourceId = CORE_ID, filename = 'PHILOSOPHY.md') { + return { format_version: FORMAT_VERSION, document_id: `urn:uuid:${randomUUID()}`, + document_filename: filename, + core_version: document.metadata.core_version, core_source_id: sourceId, + sources: { [sourceId]: checkpoint(document, 'core') }, declined: [] }; +} + +export function validateLock(lock, metadata, filename) { + if (!lock || lock.format_version !== FORMAT_VERSION || typeof lock.document_id !== 'string' || !lock.document_id + || typeof lock.core_source_id !== 'string' || !lock.core_source_id || !SEMVER.test(lock.core_version ?? '') + || !lock.sources || Array.isArray(lock.sources) || !Array.isArray(lock.declined)) throw new Error('Invalid lock header'); + if (metadata && lock.core_version !== metadata.core_version) throw new Error('Document and lock core_version disagree'); + if (typeof lock.document_filename !== 'string' || !lock.document_filename || /[/\\]/.test(lock.document_filename) + || (filename && lock.document_filename !== filename)) throw new Error('Lock belongs to a different document'); + if (!Object.hasOwn(lock.sources, lock.core_source_id)) throw new Error('Lock is missing its Core source checkpoint'); + if (lock.sources[lock.core_source_id]?.core_version !== lock.core_version) throw new Error('Core checkpoint and lock version disagree'); + for (const [id, source] of Object.entries(lock.sources)) { + if (!id || !source || !['core', 'import'].includes(source.kind) || source.format_version !== FORMAT_VERSION + || !SEMVER.test(source.philosophy_version ?? '') || !SEMVER.test(source.core_version ?? '') + || !HASH.test(source.content_hash ?? '') || !source.sections || Array.isArray(source.sections) + || !Array.isArray(source.structure) || !HASH.test(source.structure_hash ?? '')) throw new Error(`Invalid source checkpoint: ${id}`); + if (source.kind === 'core' !== (id === lock.core_source_id)) throw new Error('Checkpoint source kind does not match its identity'); + const ids = Object.keys(source.sections); + if (!ids.length || ids.some(key => !ID.test(key) || !HASH.test(source.sections[key]))) throw new Error('Invalid checkpoint section hashes'); + if (hash(JSON.stringify(source.structure)) !== source.structure_hash + || source.structure.map(entry => entry.id).sort().join('\n') !== ids.sort().join('\n')) throw new Error('Incomplete or inconsistent structure checkpoint'); + const stack = []; + for (const entry of source.structure) { + if (!entry || !Number.isInteger(entry.level) || entry.level < 1 || entry.level > 6 || typeof entry.title !== 'string') throw new Error('Invalid checkpoint structure'); + while (stack.length && stack.at(-1).level >= entry.level) stack.pop(); + if (entry.parent !== (stack.at(-1)?.id ?? null)) throw new Error('Invalid checkpoint parent'); + stack.push(entry); + } + } + const seen = new Set(); + for (const declined of lock.declined) { + if (!declined || !Object.hasOwn(lock.sources, declined.source_id) || !ID.test(declined.id ?? '') + || !(declined.incoming_hash === null || HASH.test(declined.incoming_hash ?? ''))) throw new Error('Invalid declined entry'); + const key = JSON.stringify([declined.source_id, declined.id]); + if (seen.has(key)) throw new Error('Duplicate declined entry'); + seen.add(key); + } + return lock; +} diff --git a/scripts/lib/operations.js b/scripts/lib/operations.js new file mode 100644 index 0000000..eb02488 --- /dev/null +++ b/scripts/lib/operations.js @@ -0,0 +1,229 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { coreWarnings, parseFrontmatter, renderFrontmatter, SEMVER } from './frontmatter.js'; +import { hash, parseDocument, withoutExtensions } from './sections.js'; +import { checkpoint, initialLock, validateLock } from './lock.js'; +import { CORE_FILE, CORE_ID, read, json, serialize, stat, lockPath, assertWritable, assertNoPending, + digestFile, writePair, writeNew, assertPrivateRecord } from './io.js'; + +export function resolvePhilosophy({ cwd = process.cwd(), philosophy } = {}) { + cwd = path.resolve(cwd); + let candidates; + if (philosophy) candidates = [path.resolve(cwd, philosophy)]; + else { + let root; + try { root = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } + catch { root = cwd; } + candidates = [...new Set([path.join(root, 'PHILOSOPHY.md'), path.join(cwd, 'PHILOSOPHY.md')])]; + } + const selectedPath = candidates.find(file => stat(file)); + if (!selectedPath) throw new Error(`Philosophy not found: ${candidates.join(', ')}. ${philosophy ? 'Correct the explicit path' : 'Use organon-philosophy init'}; no Core fallback.`); + const realPath = fs.realpathSync(selectedPath); + const text = read(realPath); + let metadata = null; + const warnings = []; + try { + metadata = parseFrontmatter(text, { supported: false }).metadata; + warnings.push(...coreWarnings(metadata.core_version)); + if (metadata.format_version !== '0.1.0') warnings.push('Unsupported managed format; assessment may read the selected text, but managed writes must stop.'); + } catch (error) { warnings.push(`${error.message}; version information is unknown. Managed operations require valid metadata.`); } + return { selectedPath, realPath, referenceDirectory: path.dirname(realPath), metadata, warnings }; +} + +export function check(file, { source = false } = {}) { + file = path.resolve(file); + assertNoPending(fs.realpathSync(file)); + const document = parseDocument(read(file)); + const result = { file, realPath: fs.realpathSync(file), mode: source ? 'source' : 'managed', + sections: document.sections.length, warnings: coreWarnings(document.metadata.core_version) }; + if (!source) { + assertWritable(file); + assertNoPending(file); + const sidecar = lockPath(file); + assertWritable(sidecar); + validateLock(json(sidecar), document.metadata, path.basename(file)); + } + return result; +} + +export function initialize({ target, source = CORE_FILE, sourceId }) { + if (!target) throw new Error('--target is required'); + assertWritable(target, { absent: true }); + assertWritable(lockPath(target), { absent: true }); + if (sourceId === undefined && fs.realpathSync(source) === fs.realpathSync(CORE_FILE)) sourceId = CORE_ID; + if (typeof sourceId !== 'string' || !sourceId.trim()) throw new Error('Provide --source-id for a custom initialization source'); + assertNoPending(fs.realpathSync(source)); + const raw = read(source); + const document = parseDocument(raw); + const body = document.sections.some(section => section.id === 'extensions') ? document.body + : `${document.body}${document.body.endsWith('\n') ? '' : '\n'}## 4. Extensions\n\n`; + const metadata = { ...document.metadata, derived_from: { + source_id: sourceId, philosophy_version: document.metadata.philosophy_version, content_hash: hash(raw) } }; + const candidate = renderFrontmatter(metadata, body); + parseDocument(candidate); + writePair(target, candidate, serialize(initialLock(document, sourceId, path.basename(target))), { create: true }); + return check(target); +} + +export function exportPhilosophy({ source, out, coreOnly = false, sourceId }) { + if (!source || !out) throw new Error('--source and --out are required'); + assertWritable(out, { absent: true }); + assertNoPending(fs.realpathSync(source)); + const raw = read(source); + const document = parseDocument(raw); + const sidecar = lockPath(source); + if (stat(sidecar) && json(sidecar).document_filename === path.basename(fs.realpathSync(source))) { + const lock = validateLock(json(sidecar), document.metadata); + sourceId ??= lock.document_id; + } else if (fs.realpathSync(source) === fs.realpathSync(CORE_FILE)) sourceId ??= CORE_ID; + if (typeof sourceId !== 'string' || !sourceId.trim()) throw new Error('Provide --source-id for a source without a managed document identity'); + const metadata = { ...document.metadata, derived_from: { + source_id: sourceId, philosophy_version: document.metadata.philosophy_version, content_hash: hash(raw) } }; + const body = coreOnly ? withoutExtensions(document) : document.body; + const output = renderFrontmatter(metadata, body); + parseDocument(output); + writeNew(out, output); + return { out: path.resolve(out), source_id: sourceId, content_hash: hash(output), core_only: coreOnly }; +} + +export function statusOf(base, ours, theirs) { + if (ours === theirs) return ours === base ? 'unchanged' : 'converged'; + if (ours === base) return 'theirs-changed'; + if (theirs === base) return 'ours-changed'; + return 'conflict'; +} + +const changeType = (oldHash, newHash) => oldHash === newHash ? 'unchanged' + : oldHash === null ? 'added' : newHash === null ? 'deleted' : 'modified'; +const sectionHashes = document => Object.fromEntries(document.sections.map(section => [section.id, section.hash])); + +export function classify({ ours, theirs, sourceId, kind, baseFile }) { + if (!ours || !theirs || !sourceId?.trim() || !['core', 'import'].includes(kind)) throw new Error('--ours, --theirs, --source-id and --kind core|import are required'); + ours = path.resolve(ours); theirs = path.resolve(theirs); + assertNoPending(ours); + assertNoPending(fs.realpathSync(theirs)); + const local = parseDocument(read(ours)); + const incoming = parseDocument(read(theirs)); + const sidecar = lockPath(ours); + const lock = validateLock(json(sidecar), local.metadata, path.basename(ours)); + if ((sourceId === lock.core_source_id) !== (kind === 'core')) throw new Error('Source identity and kind disagree; imports cannot advance the Core checkpoint'); + const base = Object.hasOwn(lock.sources, sourceId) ? lock.sources[sourceId] : null; + if (base && base.kind !== kind) throw new Error('Source kind changed'); + let baseText = null; + if (baseFile) { + if (!base) throw new Error('No checkpoint against which to verify --base-file'); + baseText = read(baseFile); + const verified = parseDocument(baseText); + if (verified.content_hash !== base.content_hash || verified.structure_hash !== base.structure_hash + || serialize(sectionHashes(verified)) !== serialize(base.sections)) throw new Error('Base file does not match the source checkpoint'); + } + const o = sectionHashes(local), t = sectionHashes(incoming), b = base?.sections ?? {}; + const ids = [...new Set([...Object.keys(o), ...Object.keys(t), ...Object.keys(b)])]; + const sections = ids.map(id => { + const O = o[id] ?? null, T = t[id] ?? null, B = b[id] ?? null; + return { id, base: base ? B : undefined, ours: O, theirs: T, + status: base ? statusOf(B, O, T) : O === T ? 'equal' : 'different', + ours_change: base ? changeType(B, O) : null, + theirs_change: base ? changeType(B, T) : null, + difference: changeType(O, T), + suppressed: lock.declined.some(entry => entry.source_id === sourceId && entry.id === id && entry.incoming_hash === T) }; + }); + return { format_version: '0.1.0', mode: base ? 'checkpoint' : 'two-way', source_id: sourceId, kind, + baseTextAvailable: baseText !== null, ...(baseText !== null ? { base_text: baseText } : {}), + metadata: { ours: local.metadata, theirs: incoming.metadata }, + bindings: { ours: { path: ours, hash: digestFile(ours) }, theirs: { path: theirs, hash: digestFile(theirs) }, + lock: { path: sidecar, hash: digestFile(sidecar) } }, + sections, structure: { base: base?.structure_hash ?? null, ours: local.structure_hash, theirs: incoming.structure_hash, + status: base ? statusOf(base.structure_hash, local.structure_hash, incoming.structure_hash) + : local.structure_hash === incoming.structure_hash ? 'equal' : 'different', + ours_layout: local.structure, theirs_layout: incoming.structure, + ...(base ? { base_layout: base.structure } : {}) }, + warnings: coreWarnings(incoming.metadata.core_version) }; +} + +function verifyBindings(bindings) { + for (const [label, binding] of Object.entries(bindings)) { + if (!binding || typeof binding.path !== 'string' || digestFile(binding.path) !== binding.hash) throw new Error(`Stale ${label}: reclassify and review the changed inputs`); + } +} + +function validateCandidate(report, candidateText, decisions) { + const candidate = parseDocument(candidateText); + if (!decisions || !decisions.sections || Array.isArray(decisions.sections) + || !['ours', 'theirs', 'manual', 'agent'].includes(decisions.structure) + || !SEMVER.test(decisions.philosophy_version ?? '')) throw new Error('Invalid decisions: provide sections, structure and philosophy_version'); + const c = sectionHashes(candidate); + for (const [id, choice] of Object.entries(decisions.sections)) { + if (!['ours', 'theirs', 'decline', 'manual', 'agent'].includes(choice)) throw new Error(`Invalid section decision: ${id}`); + if (!report.sections.some(row => row.id === id) && !(Object.hasOwn(c, id) && ['manual', 'agent'].includes(choice))) throw new Error(`Decision references an unknown section: ${id}`); + } + for (const row of report.sections) { + const incomingChange = report.mode === 'two-way' || row.base !== row.theirs; + const needsDecision = row.ours !== row.theirs && incomingChange && !row.suppressed; + const choice = decisions.sections[row.id] ?? (needsDecision ? null : row.suppressed ? 'decline' : 'ours'); + if (!choice) throw new Error(`Missing decision for ${row.id}`); + const expected = choice === 'theirs' ? row.theirs : ['ours', 'decline'].includes(choice) ? row.ours : undefined; + if (expected !== undefined && (c[row.id] ?? null) !== expected) throw new Error(`Candidate does not implement ${choice} for ${row.id}`); + } + for (const id of Object.keys(c)) { + if (!report.sections.some(row => row.id === id) && !['manual', 'agent'].includes(decisions.sections[id])) throw new Error(`Candidate adds ${id} without a manual/agent decision`); + } + if (['ours', 'theirs'].includes(decisions.structure) && candidate.structure_hash !== report.structure[decisions.structure]) throw new Error('Candidate does not implement the selected structure'); + const expectedCore = report.kind === 'core' ? report.metadata.theirs.core_version : report.metadata.ours.core_version; + if (candidate.metadata.core_version !== expectedCore || candidate.metadata.philosophy_version !== decisions.philosophy_version + || candidate.metadata.format_version !== report.metadata.ours.format_version + || JSON.stringify(candidate.metadata.derived_from) !== JSON.stringify(report.metadata.ours.derived_from)) throw new Error('Candidate metadata does not implement the confirmed version/lineage policy'); + return candidate; +} + +export function prepare({ report: reportFile, candidate: candidateFile, decisions: decisionsFile, record }) { + if (!reportFile || !candidateFile || !decisionsFile || !record) throw new Error('--report, --candidate, --decisions and --record are required'); + const supplied = json(reportFile); + verifyBindings(supplied.bindings); + const ours = supplied.bindings.ours.path; + assertWritable(ours); + assertWritable(lockPath(ours)); + const report = classify({ ours, theirs: supplied.bindings.theirs.path, sourceId: supplied.source_id, kind: supplied.kind }); + const decisions = json(decisionsFile); + const candidateText = read(candidateFile); + validateCandidate(report, candidateText, decisions); + record = assertPrivateRecord(ours, record); + return { format_version: '0.1.0', source_id: report.source_id, kind: report.kind, + bindings: { ...report.bindings, candidate: { path: path.resolve(candidateFile), hash: hash(candidateText) }, + decisions: { path: path.resolve(decisionsFile), hash: digestFile(decisionsFile) }, record: { path: record, hash: digestFile(record) } } }; +} + +export function applyPlan(planFile) { + const plan = json(planFile); + if (plan.format_version !== '0.1.0') throw new Error('Unsupported apply plan'); + if (Object.keys(plan.bindings ?? {}).sort().join(',') !== 'candidate,decisions,lock,ours,record,theirs') throw new Error('Incomplete apply plan bindings'); + verifyBindings(plan.bindings); + const target = plan.bindings.ours.path; + assertWritable(target); + assertWritable(lockPath(target)); + if (plan.bindings.lock.path !== lockPath(target)) throw new Error('Plan lock path does not match target'); + assertPrivateRecord(target, plan.bindings.record.path); + const report = classify({ ours: target, theirs: plan.bindings.theirs.path, sourceId: plan.source_id, kind: plan.kind }); + const decisions = json(plan.bindings.decisions.path); + const candidateText = read(plan.bindings.candidate.path); + const candidate = validateCandidate(report, candidateText, decisions); + const lock = json(lockPath(target)); + const incoming = parseDocument(read(plan.bindings.theirs.path)); + lock.sources = { ...lock.sources, [plan.source_id]: checkpoint(incoming, plan.kind) }; + if (plan.kind === 'core') lock.core_version = incoming.metadata.core_version; + const currentSourceDeclines = []; + for (const row of report.sections) { + const choice = decisions.sections[row.id] ?? (row.suppressed ? 'decline' : 'ours'); + const reviewable = report.mode === 'two-way' || row.base !== row.theirs; + if (row.ours !== row.theirs && (((reviewable || row.suppressed) && choice === 'ours') || choice === 'decline')) { + currentSourceDeclines.push({ source_id: plan.source_id, id: row.id, incoming_hash: row.theirs }); + } + } + lock.declined = [...lock.declined.filter(entry => entry.source_id !== plan.source_id), ...currentSourceDeclines]; + validateLock(lock, candidate.metadata); + verifyBindings(plan.bindings); + writePair(target, candidateText, serialize(lock), { verifyInputs: () => verifyBindings(plan.bindings) }); + return { applied: target, core_version: lock.core_version, philosophy_version: candidate.metadata.philosophy_version, + record: plan.bindings.record.path }; +} diff --git a/scripts/lib/sections.js b/scripts/lib/sections.js new file mode 100644 index 0000000..59235a6 --- /dev/null +++ b/scripts/lib/sections.js @@ -0,0 +1,64 @@ +import { createHash } from 'node:crypto'; +import { normalize, parseFrontmatter } from './frontmatter.js'; + +export const ID = /^(?:extensions|[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)$/; +export const hash = text => createHash('sha256').update(text).digest('hex'); + +export function parseDocument(raw) { + const { metadata, body } = parseFrontmatter(raw); + const lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? []; + const heads = []; + let fence = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].replace(/\n$/, ''); + if (fence) { + if (new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(line)) fence = null; + continue; + } + const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (opening) { + if (opening[1][0] === '`' && opening[2].includes('`')) throw new Error('Backtick fence info must not contain backticks'); + fence = { char: opening[1][0], length: opening[1].length }; continue; + } + const heading = /^(#{1,6}) (.+)$/.exec(line); + if (heading) { + const marker = /^\n?$/.exec(lines[i + 1] ?? ''); + if (!marker || !ID.test(marker[1])) throw new Error(`Missing or invalid stable ID after heading: ${line}`); + heads.push({ start: i, id: marker[1], level: heading[1].length, title: heading[2] }); + i++; + } else if (/organon:id/.test(line) && /^\s*\n\nIntroduction.\n\n## A\n\n\n${a}\n\n## B\n\n\n${b}\n`); +} +function managed(t, options) { + const dir = fixture(t, options), source = path.join(dir, 'upstream.md'), ours = path.join(dir, 'PHILOSOPHY.md'); + write(source, example()); + initialize({ target: ours, source, sourceId: CORE_ID }); + return { dir, source, ours }; +} +function reportFor(ctx, sourceId = CORE_ID, kind = 'core') { + return classify({ ours: ctx.ours, theirs: ctx.source, sourceId, kind }); +} +function planFor(ctx, report, candidate, decisions) { + const directory = path.join(ctx.dir, '.local/iterations/merges'); + fs.mkdirSync(directory, { recursive: true }); + const files = Object.fromEntries(['report', 'candidate', 'decisions', 'record', 'plan'].map(key => [key, path.join(directory, `${key}.${['candidate', 'record'].includes(key) ? 'md' : 'json'}`)])); + write(files.report, report); write(files.candidate, candidate); write(files.decisions, decisions); + write(files.record, '# Adoption record\n\nObject and baseline; objective; grounds; alternatives; assessment; decision.\n'); + write(files.plan, prepare(files)); + return files; +} + +test('source checks cover 22 matching bilingual IDs; root mirror needs no lock', () => { + const english = parseDocument(read(CORE_FILE)); + const chinese = parseDocument(read(path.join(ROOT, 'OrganonCore/zh/PHILOSOPHY.md'))); + assert.equal(english.sections.length, 22); + assert.deepEqual(english.sections.map(s => s.id), chinese.sections.map(s => s.id)); + assert.equal(english.sections.map(s => s.raw).join(''), english.body); + // Migration baselines: update only alongside an explicitly reviewed philosophy revision. + assert.equal(hash(english.body.replace(/^\n/gm, '')), '7bd696f61572c63a79f58c2ba5f44673dc032b4fc9b6142f39991ce8f03a087a'); + assert.equal(hash(chinese.body.replace(/^\n/gm, '')), 'e4b07abafb9bace546a4a193e49e75b5f3a6f0c9a40fe261a921517bd946f24b'); + assert.equal(check(CORE_FILE, { source: true }).mode, 'source'); + assert.equal(check(path.join(ROOT, 'PHILOSOPHY.md'), { source: true }).realPath, fs.realpathSync(CORE_FILE)); + assert.equal(fs.existsSync(path.join(ROOT, 'PHILOSOPHY.lock.json')), false); +}); + +test('frontmatter subset rejects unsupported syntax, duplicates and unsupported format', () => { + for (const bad of [ + example().replace('format_version: 0.1.0', 'format_version: "0.1.0"'), + example().replace('derived_from: null', 'unknown: value\nderived_from: null'), + example().replace('derived_from: null', 'derived_from: null\nderived_from: null'), + example().replace('format_version: 0.1.0', 'format_version: 0.2.0'), + example().replace('derived_from: null', 'derived_from:\n source_id: "x"\n philosophy_version: 0.1.0\n content_hash: "' + '0'.repeat(64) + '"'), + ]) assert.throws(() => parseDocument(bad)); + const source = { source_id: 'a "quoted" source', philosophy_version: '0.1.0', content_hash: 'a'.repeat(64) }; + assert.deepEqual(parseFrontmatter(renderFrontmatter({ ...metadata, derived_from: source }, '# text')).metadata.derived_from, source); +}); + +test('heading units cover parent provisions and ignore fenced examples', () => { + const body = example().replace('Alpha.', 'Parent commitment.\n\n### Child\n\n\n```md\n## Unmanaged example\n```'); + const document = parseDocument(body); + assert.equal(document.sections.length, 4); + assert.match(document.sections[1].body, /Parent commitment/); + assert.equal(document.sections[2].parent, 'example.a'); + assert.throws(() => parseDocument(example().replace('example.b', 'example.a')), /Duplicate/); + assert.throws(() => parseDocument(example().replace('organon.preamble', 'example.intro')), /preamble/); + assert.throws(() => parseDocument(example().replace('## A\n', 'A\n---')), /setext/); + assert.throws(() => parseDocument(example() + '\n```bad`\n## Hidden\n'), /fence/); +}); + +test('hashes normalize line endings only; title and body have separate identities', () => { + const a = parseDocument(example()), crlf = parseDocument(example().replaceAll('\n', '\r\n')); + assert.deepEqual(a.sections.map(s => s.hash), crlf.sections.map(s => s.hash)); + assert.notEqual(a.content_hash, crlf.content_hash); + const space = parseDocument(example().replace('Alpha.', 'Alpha. ')); + assert.notEqual(a.sections[1].hash, space.sections[1].hash); + const renamed = parseDocument(example().replace('## A', '## Renamed')); + assert.equal(a.sections[1].hash, renamed.sections[1].hash); + assert.notEqual(a.structure_hash, renamed.structure_hash); +}); + +test('five mutually exclusive statuses cover presence and deletion combinations', () => { + const cases = [ + ['a', 'a', 'a', 'unchanged'], ['a', 'b', 'b', 'converged'], + ['a', 'a', 'b', 'theirs-changed'], ['a', 'b', 'a', 'ours-changed'], ['a', 'b', 'c', 'conflict'], + [null, null, 'b', 'theirs-changed'], [null, 'a', 'b', 'conflict'], + ['a', null, 'a', 'ours-changed'], ['a', 'b', null, 'conflict'], ['a', null, null, 'converged'], [null, 'a', null, 'ours-changed'], + ]; + for (const [b, o, t, expected] of cases) assert.equal(statusOf(b, o, t), expected); +}); + +test('init creates a regular managed copy, lineage and empty Extensions; never overwrites', t => { + const ctx = managed(t, { git: true }); + const document = parseDocument(read(ctx.ours)), lock = json(lockPath(ctx.ours)); + assert.equal(document.sections.at(-1).id, 'extensions'); + for (const section of parseDocument(read(ctx.source)).sections) { + assert.equal(document.sections.find(s => s.id === section.id).hash, section.hash); + } + assert.equal(document.metadata.derived_from.content_hash, digestFile(ctx.source)); + assert.equal(lock.sources[CORE_ID].sections['extensions'], undefined); + assert.equal(check(ctx.ours).mode, 'managed'); + assert.throws(() => initialize({ target: ctx.ours, source: ctx.source }), /already exists/); + write(ctx.ours, read(ctx.ours).replace('Alpha.', 'Locally revised.')); + assert.equal(check(ctx.ours).mode, 'managed'); +}); + +test('resolution uses repository root then cwd and never falls back after explicit failure', t => { + const ctx = managed(t, { git: true }); + const child = path.join(ctx.dir, 'nested'); fs.mkdirSync(child); + write(path.join(child, 'PHILOSOPHY.md'), example('Child only.')); + assert.equal(resolvePhilosophy({ cwd: child }).realPath, fs.realpathSync(ctx.ours)); + assert.throws(() => resolvePhilosophy({ cwd: child, philosophy: 'missing.md' }), /no Core fallback/); + fs.unlinkSync(ctx.ours); + assert.equal(resolvePhilosophy({ cwd: child }).realPath, fs.realpathSync(path.join(child, 'PHILOSOPHY.md'))); + fs.unlinkSync(path.join(child, 'PHILOSOPHY.md')); + assert.throws(() => resolvePhilosophy({ cwd: child }), /init/); +}); + +test('core merge rejects stale candidate and lock while a valid plan applies exact choices', t => { + const ctx = managed(t); + write(ctx.source, example('Incoming.').replace('core_version: 0.1.0', 'core_version: 0.1.1')); + const report = reportFor(ctx); + assert.equal(report.sections.find(s => s.id === 'example.a').status, 'theirs-changed'); + const candidate = read(ctx.ours).replace('Alpha.', 'Incoming.').replace('core_version: 0.1.0', 'core_version: 0.1.1').replace('philosophy_version: 0.1.0', 'philosophy_version: 1.0.0'); + const decisions = { sections: { 'example.a': 'theirs' }, structure: 'ours', philosophy_version: '1.0.0' }; + const files = planFor(ctx, report, candidate, decisions); + const oldLock = read(lockPath(ctx.ours)); + write(lockPath(ctx.ours), `${oldLock}\n`); + assert.throws(() => applyPlan(files.plan), /Stale lock/); + write(lockPath(ctx.ours), oldLock); + write(files.candidate, candidate + '\n'); + assert.throws(() => applyPlan(files.plan), /Stale candidate/); + write(files.candidate, candidate); + applyPlan(files.plan); + assert.equal(read(ctx.ours), candidate); + assert.equal(json(lockPath(ctx.ours)).core_version, '0.1.1'); + assert.equal(reportFor(ctx).sections.find(s => s.id === 'example.a').status, 'unchanged'); + assert.throws(() => applyPlan(files.plan), /Stale/); +}); + +test('declines survive source checkpoint advance and local edits; changed source reopens proposal', t => { + const ctx = managed(t); + write(ctx.source, example('First incoming.')); + const decisions = { sections: { 'example.a': 'decline' }, structure: 'ours', philosophy_version: '0.1.0' }; + const files = planFor(ctx, reportFor(ctx), read(ctx.ours), decisions); + applyPlan(files.plan); + let row = reportFor(ctx).sections.find(s => s.id === 'example.a'); + assert.equal(row.status, 'ours-changed'); assert.equal(row.suppressed, true); + const again = planFor(ctx, reportFor(ctx), read(ctx.ours), { ...decisions, sections: { 'example.a': 'ours' } }); + applyPlan(again.plan); + assert.equal(reportFor(ctx).sections.find(s => s.id === 'example.a').suppressed, true); + assert.equal(json(lockPath(ctx.ours)).sources[CORE_ID].sections['example.a'], parseDocument(read(ctx.source)).sections[1].hash); + write(ctx.ours, read(ctx.ours).replace('Alpha.', 'A local edit.')); + assert.equal(reportFor(ctx).sections.find(s => s.id === 'example.a').suppressed, true); + write(ctx.source, example('Second incoming.')); + row = reportFor(ctx).sections.find(s => s.id === 'example.a'); + assert.equal(row.status, 'conflict'); assert.equal(row.suppressed, false); +}); + +test('delete/modify is conflict and declined deletion uses explicit absent state', t => { + const ctx = managed(t); + write(ctx.ours, read(ctx.ours).replace('Beta.', 'Local revision.')); + const upstream = parseDocument(read(ctx.source)); + write(ctx.source, renderFrontmatter(upstream.metadata, upstream.sections.filter(s => s.id !== 'example.b').map(s => s.raw).join(''))); + const report = reportFor(ctx), row = report.sections.find(s => s.id === 'example.b'); + assert.equal(row.status, 'conflict'); assert.equal(row.theirs_change, 'deleted'); + applyPlan(planFor(ctx, report, read(ctx.ours), { sections: { 'example.b': 'decline' }, structure: 'ours', philosophy_version: '0.1.0' }).plan); + assert.deepEqual(json(lockPath(ctx.ours)).declined, [{ source_id: CORE_ID, id: 'example.b', incoming_hash: null }]); +}); + +test('foreign import is two-way, scopes decline memory and does not advance Core', t => { + const ctx = managed(t); + write(ctx.source, example('Foreign input.').replace('core_version: 0.1.0', 'core_version: 0.9.0')); + const before = json(lockPath(ctx.ours)).sources[CORE_ID]; + const report = reportFor(ctx, 'urn:example:foreign', 'import'); + assert.equal(report.mode, 'two-way'); assert.equal(report.sections.find(s => s.id === 'example.a').status, 'different'); + const decisions = { sections: { 'example.a': 'decline', extensions: 'ours' }, structure: 'ours', philosophy_version: '0.1.0' }; + applyPlan(planFor(ctx, report, read(ctx.ours), decisions).plan); + const lock = json(lockPath(ctx.ours)); + assert.equal(lock.core_version, '0.1.0'); assert.deepEqual(lock.sources[CORE_ID], before); + assert.equal(reportFor(ctx, 'urn:example:foreign', 'import').mode, 'checkpoint'); + assert.equal(reportFor(ctx, 'urn:example:different', 'import').sections.find(s => s.id === 'example.a').suppressed, false); + assert.throws(() => reportFor(ctx, CORE_ID, 'import'), /kind disagree/); +}); + +test('verified old source is optional; provenance alone cannot supply base text', t => { + const ctx = managed(t), old = path.join(ctx.dir, 'old.md'); write(old, read(ctx.source)); + write(ctx.source, example('Changed.')); + assert.equal(reportFor(ctx).baseTextAvailable, false); + assert.equal(classify({ ours: ctx.ours, theirs: ctx.source, sourceId: CORE_ID, kind: 'core', baseFile: old }).baseTextAvailable, true); + write(old, read(old) + '\n'); + assert.throws(() => classify({ ours: ctx.ours, theirs: ctx.source, sourceId: CORE_ID, kind: 'core', baseFile: old }), /does not match/); +}); + +test('heading rename, parent and order changes are visible without invented body conflicts', t => { + const ctx = managed(t); + for (const transformed of [example().replace('## A', '## Renamed'), example().replace('## B', '### B'), + renderFrontmatter(metadata, [0, 2, 1].map(i => parseDocument(example()).sections[i].raw).join(''))]) { + write(ctx.source, transformed); + const report = reportFor(ctx); + assert.notEqual(report.structure.ours, report.structure.theirs); + assert.equal(report.structure.status, 'conflict'); // ours added Extensions; theirs changed the source structure. + assert.equal(report.sections.find(s => s.id === 'example.a').status, 'unchanged'); + } +}); + +test('export excludes marked subtree after renaming/moving and preserves source bytes', t => { + const ctx = managed(t); + write(ctx.ours, read(ctx.ours).replace('## 4. Extensions', '## Local material') + '\n### Notes\n\n\nPrivate example.\n'); + const original = read(ctx.ours), full = path.join(ctx.dir, 'full.md'), subset = path.join(ctx.dir, 'subset.md'); + exportPhilosophy({ source: ctx.ours, out: full }); + exportPhilosophy({ source: ctx.ours, out: subset, coreOnly: true }); + assert.match(read(full), /Private example/); assert.doesNotMatch(read(subset), /Private example|id extensions/); + assert.equal(parseDocument(read(full)).metadata.derived_from.source_id, json(lockPath(ctx.ours)).document_id); + assert.equal(parseDocument(read(full)).metadata.derived_from.content_hash, hash(original)); + assert.equal(read(ctx.ours), original); + assert.throws(() => exportPhilosophy({ source: ctx.source, out: path.join(ctx.dir, 'bad.md'), coreOnly: true, sourceId: 'test' }), /No Extensions/); + assert.throws(() => exportPhilosophy({ source: ctx.ours, out: full }), /already exists/); +}); + +test('incorrect or incomplete decisions cannot apply a different candidate', t => { + const ctx = managed(t); write(ctx.source, example('Incoming.')); + const report = reportFor(ctx); + assert.throws(() => planFor(ctx, report, read(ctx.ours), { sections: {}, structure: 'ours', philosophy_version: '0.1.0' }), /Missing decision/); + assert.throws(() => planFor(ctx, report, read(ctx.ours), { sections: { 'example.a': 'theirs' }, structure: 'ours', philosophy_version: '0.1.0' }), /does not implement theirs/); +}); + +test('pair failure restores original file and lock; interrupted writes can be recovered', t => { + const ctx = managed(t), original = read(ctx.ours), originalLock = read(lockPath(ctx.ours)); + const candidate = original.replace('Alpha.', 'Candidate.'); + assert.throws(() => writePair(ctx.ours, candidate, originalLock, { failAfterFirst: true }), /Injected/); + assert.equal(read(ctx.ours), original); assert.equal(read(lockPath(ctx.ours)), originalLock); + const pending = pendingPath(ctx.ours); + write(pending, { target: ctx.ours, lock: lockPath(ctx.ours), oldDocument: original, oldLock: originalLock, newDocument: candidate, newLock: originalLock }); + write(ctx.ours, candidate); + assert.throws(() => check(ctx.ours), /Interrupted transaction/); + recover(ctx.ours); + assert.equal(read(ctx.ours), original); assert.equal(fs.existsSync(pending), false); +}); + +test('recovery refuses an unrelated concurrent edit and keeps its journal', t => { + const ctx = managed(t), original = read(ctx.ours), oldLock = read(lockPath(ctx.ours)), pending = pendingPath(ctx.ours); + write(pending, { target: ctx.ours, lock: lockPath(ctx.ours), oldDocument: original, oldLock, newDocument: original + '\n', newLock: oldLock }); + write(ctx.ours, original.replace('Alpha.', 'Concurrent work.')); + assert.throws(() => recover(ctx.ours), /changed outside/); + assert.match(read(ctx.ours), /Concurrent work/); assert.equal(fs.existsSync(pending), true); +}); + +test('all write operations reject symlinks and protected Core targets without changing either', t => { + const ctx = managed(t), mirror = path.join(ctx.dir, 'mirror.md'), before = digestFile(CORE_FILE); + fs.symlinkSync(CORE_FILE, mirror); + for (const target of [mirror, path.join(ROOT, 'PHILOSOPHY.md'), CORE_FILE]) { + assert.throws(() => initialize({ target, source: ctx.source }), /symlink|protected source/); + assert.throws(() => exportPhilosophy({ source: ctx.ours, out: target }), /symlink|protected source/); + assert.throws(() => writePair(target, example(), '{}'), /symlink|protected source/); + } + const report = reportFor(ctx); + const files = planFor(ctx, report, read(ctx.ours), { sections: {}, structure: 'ours', philosophy_version: '0.1.0' }); + fs.unlinkSync(ctx.ours); fs.symlinkSync(CORE_FILE, ctx.ours); + assert.throws(() => applyPlan(files.plan), /Stale|symlink/); + assert.equal(digestFile(CORE_FILE), before); + assert.equal(fs.lstatSync(mirror).isSymbolicLink(), true); + assert.equal(fs.lstatSync(path.join(ROOT, 'PHILOSOPHY.md')).isSymbolicLink(), true); +}); + +test('CLI errors return nonzero JSON and source check is executable', () => { + const success = spawnSync(process.execPath, ['scripts/check.js', '--source', 'PHILOSOPHY.md'], { cwd: ROOT, encoding: 'utf8' }); + assert.equal(success.status, 0, success.stderr); assert.equal(JSON.parse(success.stdout).sections, 22); + const bad = spawnSync(process.execPath, ['scripts/classify.js', '--unknown'], { cwd: ROOT, encoding: 'utf8' }); + assert.equal(bad.status, 1); assert.match(JSON.parse(bad.stderr).error, /Unknown/); +}); + +test('unsupported ATX spellings cannot silently hide unnumbered sections', () => { + for (const heading of ['##', '##\tHidden', ' ## Indented']) { + assert.throws(() => parseDocument(example() + `\n${heading}\nBody.\n`), /ATX/); + } +}); + +test('pending transaction detection uses the physical target across parent aliases', t => { + const ctx = managed(t), alias = path.join(ctx.dir, 'alias'); + fs.symlinkSync(ctx.dir, alias); + const aliasFile = path.join(alias, 'PHILOSOPHY.md'); + assert.equal(pendingPath(aliasFile), pendingPath(ctx.ours)); + const old = read(ctx.ours), oldLock = read(lockPath(ctx.ours)), pending = pendingPath(ctx.ours); + const report = reportFor(ctx); + const files = planFor(ctx, report, old, { sections: {}, structure: 'ours', philosophy_version: '0.1.0' }); + write(pending, { target: ctx.ours, lock: lockPath(ctx.ours), oldDocument: old, oldLock, newDocument: old, newLock: oldLock }); + assert.throws(() => check(aliasFile), /Interrupted/); + assert.throws(() => classify({ ours: aliasFile, theirs: ctx.source, sourceId: CORE_ID, kind: 'core' }), /Interrupted/); + assert.throws(() => applyPlan(files.plan), /Interrupted/); + recover(aliasFile); + assert.equal(read(ctx.ours), old); +}); + +test('interrupted sources cannot enter export, init or another merge', t => { + const ctx = managed(t), otherDir = fixture(t), other = path.join(otherDir, 'PHILOSOPHY.md'); + initialize({ target: other, source: ctx.source, sourceId: CORE_ID }); + const old = read(ctx.ours), oldLock = read(lockPath(ctx.ours)); + write(pendingPath(ctx.ours), { target: ctx.ours, lock: lockPath(ctx.ours), oldDocument: old, oldLock, newDocument: old, newLock: oldLock }); + assert.throws(() => exportPhilosophy({ source: ctx.ours, out: path.join(ctx.dir, 'export.md') }), /Interrupted/); + assert.throws(() => initialize({ target: path.join(fixture(t), 'new.md'), source: ctx.ours, sourceId: 'custom' }), /Interrupted/); + assert.throws(() => classify({ ours: other, theirs: ctx.ours, sourceId: 'foreign', kind: 'import' }), /Interrupted/); +}); + +test('a sibling document cannot borrow the managed identity from an unrelated sidecar', t => { + const ctx = managed(t), out = path.join(ctx.dir, 'export.md'); + assert.throws(() => exportPhilosophy({ source: ctx.source, out }), /Provide --source-id/); + assert.throws(() => check(ctx.source), /different document/); + assert.throws(() => initialize({ source: ctx.source, target: path.join(fixture(t), 'PHILOSOPHY.md') }), /Provide --source-id/); +}); + +test('input validation runs under the exclusive transaction before any document write', t => { + const ctx = managed(t), old = read(ctx.ours), oldLock = read(lockPath(ctx.ours)); + assert.throws(() => writePair(ctx.ours, old + '\n', oldLock, { + verifyInputs() { + assert.equal(fs.existsSync(pendingPath(ctx.ours)), true); + write(ctx.ours, old.replace('Alpha.', 'Independent update.')); + throw new Error('Stale input after acquiring transaction'); + }, + }), /Stale input/); + assert.match(read(ctx.ours), /Independent update/); + assert.equal(read(lockPath(ctx.ours)), oldLock); + assert.equal(fs.existsSync(pendingPath(ctx.ours)), false); +}); + +test('version warnings respect the manifest range without becoming format judgments', () => { + assert.deepEqual(coreWarnings('0.1.99'), []); + assert.equal(coreWarnings('0.2.0').length, 1); + assert.deepEqual(coreWarnings('3.4.2', '>=3.4.0 <4.0.0'), []); + assert.equal(coreWarnings('3.3.99', '>=3.4.0 <4.0.0').length, 1); + assert.throws(() => coreWarnings('0.1.0', '^0.1.0'), /Unsupported/); +}); + +test('each reviewed input is bound, including source, current text, decisions and record', t => { + const ctx = managed(t); + const report = reportFor(ctx); + const files = planFor(ctx, report, read(ctx.ours), { sections: {}, structure: 'ours', philosophy_version: '0.1.0' }); + for (const [label, file] of [['ours', ctx.ours], ['theirs', ctx.source], ['decisions', files.decisions], ['record', files.record]]) { + const original = read(file); write(file, original + '\n'); + assert.throws(() => applyPlan(files.plan), new RegExp(`Stale ${label}`)); + write(file, original); + } +}); diff --git a/zh/AGENTS.md b/zh/AGENTS.md new file mode 100644 index 0000000..2ff9ba4 --- /dev/null +++ b/zh/AGENTS.md @@ -0,0 +1,15 @@ +# 在 AgentOrganon 中工作 + +本文件是[根目录 AGENTS.md](../AGENTS.md) 的中文对应文档,并承载本目录的翻译规则。翻译基准为对应英文文本;出现差异时,以英文文本为准。沿用 [Core 中文翻译规则](../OrganonCore/zh/AGENTS.md):保持相同的含义、义务或确定性强度、条件、范围和必要限定,允许自然的中文措辞差异;同步更新对应英文和中文文件。翻译核对不替代独立审查。 + +将 [OrganonCore/AGENTS.md](../OrganonCore/AGENTS.md) 与本文件一并阅读,包括其链接的哲学、审查方法和[迭代规则](../OrganonCore/skills/references/iteration.md)。外层仓库工作适用共享的维护、独立审查、措辞、授权、记录和停止规则。Core 的纯文本交付约束适用于 Core 仓库;外层仓库明确提供零依赖 Node 脚本。 + +区分工作区已采用的哲学、待采用替代文本与 Core 方法自身的约束。使用[哲学解析契约](../OrganonCore/skills/references/philosophy-resolution.md)和[格式契约](../OrganonCore/skills/references/structure.md)。外层技能在委托时传递所选基准。与 Core 的差异不自动构成采用方承诺内部的矛盾。兼容本身不支持采用,冲突本身也不否定修订理由。 + +根目录 `PHILOSOPHY.md` 是指向 Core 源文件的只读软链接,没有根目录派生 lock。相对引用从真实源文件目录解析。不得通过哲学管理写入替换它,或通过其他路径写入其 Core 目标。管理操作用于普通采用副本;Core 变更仅通过其适用的维护或吸收流程及授权执行。 + +脚本执行确定性解析、比较和文件操作。agent 与用户保留哲学判断、语义版本决定及具体采用授权。准备好的应用计划绑定输入,不证明授权或哲学正确性。行为变更须运行相关机械测试及独立的实际技能用例,先保留独立原始响应,再与预期边界比较。 + +将变更限制在授权范围内,并保留用户已有工作。私有证据及共享六段记录保存在已忽略的 `.local/`;合并报告、候选、决定、计划和记录使用 `.local/iterations/merges/`。写入记录前确认该目录已被忽略。英文文档是对应译文的正本;维护翻译对时遵循本文件的翻译规则。本次实现不翻译 SKILL.md 文件。 + +远程发布遵循 [Core 授权规则](../OrganonCore/AGENTS.md#remote-publication-authorization)。复用覆盖相同操作、目标和范围的既有授权。实施请求本身不授权 push。不得发布引用尚不可获得的 Core 提交的外层版本。 diff --git a/zh/README.md b/zh/README.md new file mode 100644 index 0000000..231ba64 --- /dev/null +++ b/zh/README.md @@ -0,0 +1,58 @@ +# AgentOrganon + +AgentOrganon 在 [OrganonCore](../OrganonCore/README.md) 外提供工作区技能与确定性文件操作。Core 包含哲学和审查方法;外层仓库包含管理采用副本的 Node 脚本。采用方工作区可以根据用户的明确决定修订或撤回承诺。 + +本仓库文档以英文为维护正本。参见[英文 README](../README.md)。 + +## 技能 + +| 技能 | 职责 | +| --- | --- | +| [organon-assess](../skills/organon-assess/SKILL.md) | 依据所选工作区哲学评估输入。 | +| [organon-absorb](../skills/organon-absorb/SKILL.md) | 审查修订该哲学的理由,在审查后落实已获授权的采用。 | +| [organon-principled-review](../skills/organon-principled-review/SKILL.md) | 按 Core 方法规定的触发条件和标准委托完整分析。 | +| [organon-wording-review](../skills/organon-wording-review/SKILL.md) | 审查措辞;不要求哲学文件。 | +| [organon-philosophy](../skills/organon-philosophy/SKILL.md) | 初始化、检查、导出、导入和合并采用副本。 | + +前三个技能使用显式指定的哲学路径;未指定时,依次在调用工作区的 Git 根目录、当前目录查找 `PHILOSOPHY.md`,不搜索子目录。显式路径无效即停止;默认候选均缺失时提示初始化。它们不会回退到随附的 Core 哲学。委托过程中始终以所选路径为评估基准,并分别标明待采用修订与 Core 方法约束。 + +兼容不构成采用理由,与既有承诺冲突也不单独否定修订理由。脚本检查并转换文件;agent 与用户评估含义、审查完整候选并决定采用。 + +## 文件与版本 + +采用方工作区使用普通文件 `PHILOSOPHY.md` 及其旁侧的 `PHILOSOPHY.lock.json`,其中 `document_filename` 标识同目录下的托管文件。[格式契约](../OrganonCore/skills/references/structure.md) 规定支持的四个 frontmatter 字段,以及每个标题及其直属正文和文首说明的稳定 ID。支持 `## Title` 这样的无缩进 ATX 标题;其他 ATX 形式和 Setext 标题会被拒绝。Core 当前的三章组织形式是初始化模板;采用方可以重组。初始化增加空 Extensions 章节,以稳定 ID `extensions` 标识,不依赖标题或位置。 + +- `format_version` 标识支持的文件格式。不支持的格式会阻止托管写入。 +- `philosophy_version` 描述语义修订,由 agent 提议、用户决定:改变或撤回承诺、含义或适用范围使用 major;保留既有承诺的新增使用 minor;保持含义的措辞或结构维护使用 patch。 +- `core_version` 记录最后一次完整审视的 Core 来源版本。超出 `package.json` 中 `organon.coreVersion` 范围时产生来源版本警告,不构成哲学裁决。 +- `derived_from` 记录导出来源。来源信息和相同版本号都不能证明共同祖先。 + +lock 保存已审视来源检查点的 hash、结构和拒绝记忆,不包含旧来源正文。本地内容不同于检查点是正常状态。被拒绝的来源单元在传入状态变化前不再提议,但实际差异仍可见。没有可验证来源检查点的导入采用双向差异模式,且绝不推进 Core 的已审视版本。 + +本仓库根目录的 `PHILOSOPHY.md` 是指向 `OrganonCore/PHILOSOPHY.md` 的相对软链接,仅用于读取;相对引用从真实源文件目录解析。根目录没有派生 lock。管理脚本拒绝穿透或替换该软链接,也拒绝写入 Core 源文件。Core 变更使用其明确的维护或吸收流程。 + +## 本地使用 + +使用 Node.js 22,无第三方运行时依赖。以下命令在本仓库执行,并要求 OrganonCore 子模块已存在。从其他目录操作时,使用脚本的绝对路径。目标的父目录必须已存在。在 Git 工作区中,初始化前先忽略 `.local/`,使恢复日志保持私有;脚本会在需要时创建日志目录。 + +```sh +node scripts/check.js --source OrganonCore/PHILOSOPHY.md +node scripts/check.js --source PHILOSOPHY.md +node scripts/init.js --target /path/to/workspace/PHILOSOPHY.md +node scripts/check.js /path/to/workspace/PHILOSOPHY.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/export.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/core-part.md --core-only +node --test +``` + +初始化和导出要求输出路径尚不存在。`--core-only` 删除由 `extensions` 标识的子树;标记缺失即停止导出。剩余文本仍属于采用方,不是官方 Core 副本。来源身份是调用方提供的标签或已保存的文档身份,不是经过认证的发布者身份。托管操作发现输入存在未完成事务时会停止,包括初始化、导出和分类时的读取;恢复操作后再继续,使用报告中给出的恢复命令。 + +通过 `organon-philosophy merge --dry-run` 请求只读差异报告。技能调用 `classify.js`;不存在已安装的 `organon-philosophy` 可执行命令。技能文档说明候选准备、决定、恢复及应用命令。报告、候选、计划和六段合并记录保存在已忽略的 `.local/iterations/merges/`。准备好的计划绑定已审视输入和候选;输入过期则停止应用。完成准备或通过文件检查不提供采用授权。 + +私有包名为 `@shendeguize/agent-organon`,版本为 `0.1.0`。安装模式和发布不在本次实现范围内。贡献前请阅读 [AGENTS.md](../AGENTS.md);远程发布遵循其引用的 Core 授权规则。 + +使用[自实践与迭代协议](docs/self-iteration.md)比较方法、保留证据并完成有界迭代。 + +## 许可证 + +MIT。 diff --git a/zh/docs/self-iteration.md b/zh/docs/self-iteration.md new file mode 100644 index 0000000..4180886 --- /dev/null +++ b/zh/docs/self-iteration.md @@ -0,0 +1,85 @@ +# 自实践与有界迭代 + +使用本协议在本仓库实践 AgentOrganon 的五个外层技能及其委托的 Core 方法,比较替代方案,并完成依据明确的一轮迭代。判断质量优先;有理由地保持现状也是有效结果。遵循 [AGENTS.md](../../AGENTS.md) 和[共享迭代规则](../../OrganonCore/skills/references/iteration.md)。本协议是可修订的方法,不是新增哲学承诺,也不构成哲学的证明。 + +## 固定对象、哲学、标准和方法 + +保存两仓库完整的当前工作树,包括未提交改动、原始要求及相关历史证据。完整快照界定保存范围,不是自动向所有角色开放的输入目录。分别记录各角色收到的材料及获准回读的源材料范围;在审计者已说明的访问范围内,审计相应的输入与保护绑定。不得仅因无关历史材料保存在同一目录中,就将访问或审计扩展至这些材料。记录环境及 agent 实际可用的指令。区分以下实验标识: + +- **R0:**被评估的仓库和任务材料。方法比较期间保持该对象不变。 +- **F0 / C0:**已采用的哲学和本轮评价标准。在候选实现前明确适用性、假设、术语、预期可观察结果及重要退步。 +- **M0 / M1…:**当前方法和候选方法版本。将所选方法与固定的 R0 对象分别提供;即使方法自身的文本是审查对象,也保留此区分。 + +这些标识属于实验记录,不新增哲学格式字段。保持 Core 纯文本及外层 Node 脚本无第三方依赖。技能组织、职责、共享规则和委托方式均可受到质疑。哲学修订在用户决定其具体含义前始终是候选。 + +## 在实现前明确范围 + +选择下述完整覆盖研究,或针对具体问题的定向阶段。执行前记录问题、纳入的案例及排除理由、必要回归、方法条件、逻辑任务数与执行调用数,以及停止条件。使用覆盖该范围的授权;未解决的重要范围选择交用户决定,不得默默扩展工作。 + +定向阶段保留下述适用的比较、重复、采用及整体修订要求。将其完成情况与完整研究的覆盖情况分别报告,并指出未执行的案例。完成一个阶段不授权执行其余案例,也不意味着完整研究已经完成。 + +### 完整覆盖案例集:17 项案例 + +每张案例卡写明原始请求、共享材料、适用依据、可观察结果、重要退步条件及获授权的操作。将包含原始任务及其既定目标与约束、共享事实与来源材料、操作授权的执行视图,与包含预期结论、适用条款答案及失败判定条件的裁判视图区分开;执行 agent 仅获得执行视图。分别标记真实仓库任务和构造情境。每个技能覆盖三项案例: + +| 技能 | 真实仓库任务 | 边界或保持现状案例 | 独立保留变式 | +| --- | --- | --- | --- | +| assess | 评估当前设计及其能力声明的依据。 | 区分有理由的稳定、支持不足和哲学冲突。 | 改变采用方承诺或适用条件。 | +| principled-review | 审查职责与替代架构。 | 审查简化中丢失的条件,以及比较性声明的支持依据。 | 审查未参与候选设计的新结构问题。 | +| absorb | 判断实践发现是否值得修订哲学。 | 对已覆盖内容给出有依据的不吸收理由。 | 在隔离副本中审查挑战既有承诺的完整候选。 | +| wording-review | 审查真实 README、技能或协议文本。 | 保留准确措辞,区分缺陷和偏好。 | 审查陌生文本中的指代、歧义和限定。 | +| philosophy | 在采用副本中实际初始化、检查、导出、导入和应用合并。 | 实践拒绝记忆、未知来源基线和不采用路径。 | 实践输入过期或中断写入恢复,并检查文件及 lock 状态。 | + +另加两项协作案例:从真实问题走到评估、必要修订和验证,不预先规定技能顺序;批评本协议和 C0,并在标准改变时保留旧判断。 + +由未参与候选编写的 agent 编制并封存保留案例。案例揭晓后不再是未知变式。所有管理写操作在明确派生的隔离普通副本中执行;真实根哲学软链接保持不变。实验授权仅适用于指定副本,不授权真实 Core 采用。有依据的不吸收结论可以完成真实 absorb 案例。 + +## 筛选架构,再比较实际运行 + +完整覆盖研究须将现有设计与至少四类替代方案比较:统一路由并保留内部专门方法;部分合并 assess 与完整审查职责;Core 直接接收工作区参数以减少外层重复;明确组织交接材料和流程状态。写明每种设计的目标、职责、交接、额外状态、预期贡献和失败风险。定向阶段明确相关替代方案,并说明排除理由。 + +依据实际问题和已确认约束,最多选择两个主要假设不同的挑战者。记录排除理由;允许全部不入围。无法消解的重要入围取舍交用户决定。静态筛选不证明行为已经改善。 + +每个纳入的案例分别使用通用任务提示、M0 和各入围候选运行。通用对照组获得相同哲学、事实和工具,但不显式调用 Organon 流程。SKILL.md 是审查对象时,各组均可将正文作为证据读取。因此比较针对显式调用流程的增益,不是对其文本一无所知的效果。 + +固定模型、推理配置、输入和工具条件。每个案例在每种方法下独立运行三次,并轮换组别顺序。完整覆盖时,两个挑战者的初始对照为 **17 × 4 × 3 = 204 次任务运行**,不含评审与修订复测。定向阶段的计数从已确认的案例及阶段结构推导,并区分逻辑任务与执行调用。每个“案例 × 方法版本 × 重复次数”使用新上下文及独立副本,或可核实的等价恢复。 + +检查自动加载指令及预期答案泄漏,保留实际加载材料。无法隔离预定差异的比较标记为不可辨识,不据此宣称增益。执行 agent 不接收预期结论。比较前保存全部原始响应、工具轨迹、失败和写入前后文件状态;不得只挑每组表现最好的一次。 + +独立评审先判断原始任务和依据,再比较隐藏方法版本标签的输出,最后接收作者理由。评审返回依据和分歧,不以投票裁决。比较中接触到的信息不得被表述为独立初判。 + +## 按后果裁决并限制修订 + +C0 评价任务后果和必要行为,不以现有技能名称、章节数量或委托顺序作为标准答案。检查适用性、结论强度的支持依据、一致性条件、反例与未知、职责与授权,以及建议是否解决实际问题。阅读量、耗时和委托次数属于次要证据,不合成哲学质量总分。 + +候选只有具备具体判断改善、相应依据,且没有未处理的重要退步,才能进入采用决策。预先定义的重要退步包括:未说明便改变基准、伪造依据、越权采用、遗漏足以改变结论的条件,以及破坏必要文件保护。一次有依据的重要失败必须处理;两次成功重复不能抵消它。其他重要质量取舍、具体哲学变更,以及现有依据无法消解的重大评审分歧,由用户决定。 + +每项修订须连接具体问题、因果假设和预期改善,并在最小负责层修改。复测受影响对照和必要回归;方法变更需要实际行为验证和新编制的独立变式。每项受影响的对照仍要求每种方法有三次有效运行。只有输入、R0、F0、C0、方法版本以及模型和工具配置均保持不变且可核实时,才可复用旧输出;单次运行或不同配置下的结果不能满足该要求。初测后,本轮整体最多允许 **两轮候选修订**,不是每个案例或候选各两轮。仅凭新依据继续。问题解决、没有新依据或到达上限即停止;到限仍存在的失败保留为未决。 + +F1 或 C1 获准后,保留旧判断及其成立条件。停止不再具有共同适用基准或标准的混合比较。明确任何新实验的范围。重命名版本或重启实验不重置本轮修订上限;扩展上限需要用户的新决定。新标准不得追认旧失败为通过。 + +## 采用、检查实际仓库并报告限度 + +将[六段记录](../../OrganonCore/skills/references/iteration.md#six-part-record)、快照、案例卡、原始运行、比较及用户决定保存在已忽略的 `.local/iterations/`。各阶段使用新的输出位置,不在原处续写已封存的记录。后续补充和更正分别保存,并链接至原记录。若已封存的材料被意外修改,须同时保留原件、修改后的版本及恢复历史;最终哈希相同不能证明期间未发生变化。 + +每项交付前核对对应证据: + +| 交付 | 完成依据与自检 | +| --- | --- | +| 基线与协议 | 完整工作树快照、环境、F0/C0、已确认的范围及相应案例卡(完整覆盖时为 17 张);区分对象、方法与标准。 | +| 当前实践与筛选 | 当前案例的实际结果及入围或排除理由;不为实践技能而强迫修改。 | +| 候选对照 | 全部原始证据、独立初判及逐项比较;检查材料一致性、污染和反例。 | +| 最小修订 | 每项修改的问题和假设、受影响的复测及必要回归结果。 | +| 采用与收尾 | 实际改动符合已审阅候选和授权;逐项记录采用、保留、否决或未决,并分别报告阶段完成情况与完整研究的覆盖情况。 | + +复用现有 CLI 和 agent 编排,不预建评测平台。审查修改后的英文措辞,并单独核对译文含义。在仓库根目录执行相关机械检查: + +```sh +rtk proxy node --test +rtk proxy node scripts/check.js --source PHILOSOPHY.md +rtk proxy node scripts/check.js --source OrganonCore/PHILOSOPHY.md +``` + +经审查和授权的采用形成 **R1** 后,对实际结果仓库执行自应用检查,与固定 R0 的方法对照分开记录。对输出、方法和标准执行一次有界自检与独立交叉审查,不形成递归审查链。 + +交付本维护协议、私有证据、经过验证和授权的最小修改,以及区分机械检查、观察到的行为、具体改善、未决问题和支持限度的报告。本协议的授权不含远程发布。结论仅适用于本仓库、所测任务和已记录的模型配置。三次重复用于观察差异,不证明普遍有效性或哲学正确性。 From 0a03e5c23cb5e64d6f927abc777f0a0cff04410b Mon Sep 17 00:00:00 2001 From: YANG Jingyu Date: Sun, 13 Sep 2026 22:52:39 +0800 Subject: [PATCH 2/5] fix: align reviewed philosophy hashes and delegate Lean tooling to Core Update fixed bilingual source expectations to the approved Core 0.1.3 bodies without weakening the hash guard. Forward Lean skills, checkers and shared parsing to the Core implementation while retaining caller-selected baselines and existing interfaces. Validation: outer 87/87; Core 53/53; current Lean manuscript checks pass. --- AGENTS.md | 2 +- OrganonCore | 2 +- README.md | 6 ++ docs/self-iteration.md | 2 +- lean/README.md | 14 +++ lean/VALIDATION.md | 3 + scripts/lib/frontmatter.js | 85 +------------------ scripts/lib/sections.js | 65 +------------- skills/organon-lean-natural-language/SKILL.md | 10 +++ skills/organon-leanify-prove/SKILL.md | 12 +++ .../references/manuscript-format.md | 3 + .../references/run-format.md | 3 + skills/organon-leanify-prove/scripts/check.js | 10 +++ .../scripts/manuscript.js | 1 + tests/core-compatibility.test.js | 66 ++++++++++++++ tests/management.test.js | 6 +- zh/README.md | 6 ++ zh/docs/self-iteration.md | 2 +- 18 files changed, 145 insertions(+), 153 deletions(-) create mode 100644 lean/README.md create mode 100644 lean/VALIDATION.md create mode 100644 skills/organon-lean-natural-language/SKILL.md create mode 100644 skills/organon-leanify-prove/SKILL.md create mode 100644 skills/organon-leanify-prove/references/manuscript-format.md create mode 100644 skills/organon-leanify-prove/references/run-format.md create mode 100644 skills/organon-leanify-prove/scripts/check.js create mode 100644 skills/organon-leanify-prove/scripts/manuscript.js create mode 100644 tests/core-compatibility.test.js diff --git a/AGENTS.md b/AGENTS.md index 8b998c6..f8bd3b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Working on AgentOrganon -Read [OrganonCore/AGENTS.md](OrganonCore/AGENTS.md) together with this file, including its linked philosophy, review methods, and [iteration rules](OrganonCore/skills/references/iteration.md). Apply the shared maintenance, independent review, wording, authorization, record, and stopping rules to outer repository work. Core's text-only delivery constraint applies to the Core repository; the outer repository deliberately supplies zero-dependency Node scripts. +Read [OrganonCore/AGENTS.md](OrganonCore/AGENTS.md) together with this file, including its linked philosophy, review methods, and [iteration rules](OrganonCore/skills/references/iteration.md). Apply the shared maintenance, independent review, wording, authorization, record, and stopping rules to outer repository work. Core owns Lean implementations, checking tools and shared pure parsing; the outer repository supplies philosophy-management scripts and compatible Lean forwarding entrypoints. Node tools in both repositories have no third-party dependencies. Keep the adopted workspace philosophy, a proposed replacement, and the Core method's constraints distinct. Use the [philosophy resolution contract](OrganonCore/skills/references/philosophy-resolution.md) and [format contract](OrganonCore/skills/references/structure.md). Outer skills pass the selected baseline through delegation. A difference from Core is not automatically a contradiction within an adopter's commitments. Compatibility alone does not warrant adoption, and conflict alone does not defeat revision reasons. diff --git a/OrganonCore b/OrganonCore index ff9baa0..b3631f9 160000 --- a/OrganonCore +++ b/OrganonCore @@ -1 +1 @@ -Subproject commit ff9baa0adaf3bcb99e055bf8f2d567ed8de9244c +Subproject commit b3631f9a13f3b2200524fe0bbc5d167b22c4e982 diff --git a/README.md b/README.md index dc1a39b..ad5f401 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ English is the maintained source for this repository's documentation. See the [C | [organon-principled-review](skills/organon-principled-review/SKILL.md) | Delegate full analysis under the Core method's stated triggers and standards. | | [organon-wording-review](skills/organon-wording-review/SKILL.md) | Review wording; no philosophy file is required. | | [organon-philosophy](skills/organon-philosophy/SKILL.md) | Initialize, check, export, import, and merge adopted copies. | +| [organon-leanify-prove](skills/organon-leanify-prove/SKILL.md) | Review source fidelity before using Lean proofs and countermodels as evidence about philosophical claims. | +| [organon-lean-natural-language](skills/organon-lean-natural-language/SKILL.md) | Produce code-derived backtranslations and source–Lean manuscripts, with optional line explanations. | The first three skills use an explicitly supplied philosophy path, or look for `PHILOSOPHY.md` first at the calling workspace's Git root and then in its current directory. They do not search descendants. An invalid explicit path stops the operation; missing default candidates prompt initialization. They never fall back to the bundled Core philosophy. The selected path remains the assessment baseline through delegation; proposed revisions and Core method constraints are identified separately. @@ -49,6 +51,8 @@ Initialization and export require absent output paths. `--core-only` removes the Use `organon-philosophy merge --dry-run` to request a read-only difference report. The skill invokes `classify.js`; there is no installed `organon-philosophy` executable. The skill documents candidate preparation, decisions, recovery, and application commands. Keep reports, candidates, plans, and six-part merge records in ignored `.local/iterations/merges/`. A prepared plan binds the reviewed inputs and candidate; stale inputs stop application. Preparation and successful file checks do not supply adoption authorization. +Invoke the Lean skills by their linked entrypoints. They are agent instructions, not installed executables. The proof workflow uses installed Lean `v4.33.1` and only its core library; its [run format](skills/organon-leanify-prove/references/run-format.md) describes the project and `node skills/organon-leanify-prove/scripts/check.js ` checker. Mechanical success does not certify source fidelity. The translation skill also works independently without original prose; request `--explain-lines` for a detailed appendix. Private proof runs remain in ignored `.local/`. + The private package name is `@shendeguize/agent-organon`, version `0.1.0`. Installation modes and release publication are outside this implementation. Read [AGENTS.md](AGENTS.md) before contributing; remote publication follows the referenced Core authorization rules. Use the [self-practice and iteration protocol](docs/self-iteration.md) to compare methods, retain evidence, and complete a bounded iteration. @@ -56,3 +60,5 @@ Use the [self-practice and iteration protocol](docs/self-iteration.md) to compar ## License MIT. + +The implementation of both Lean skills, their checker and current evidence now belongs to [OrganonCore](OrganonCore/lean/README.md). The outer entrypoints retain workspace-baseline resolution and forward to the corresponding Core skills; the existing checker command remains compatible. diff --git a/docs/self-iteration.md b/docs/self-iteration.md index 7fc9966..47c6497 100644 --- a/docs/self-iteration.md +++ b/docs/self-iteration.md @@ -10,7 +10,7 @@ Preserve both repositories' complete current worktrees, including uncommitted ch - **F0 / C0:** the adopted philosophy and the criteria for this iteration. State applicability, assumptions, terms, expected observable outcomes, and important regressions before candidate implementation. - **M0 / M1…:** the current method and candidate method versions. Supply the chosen method separately from the fixed R0 object, including when the method's own text is the object of review. -These identifiers belong to the experiment record; they add no philosophy-format fields. Keep Core text-only and outer Node scripts free of third-party dependencies. Skill organization, responsibilities, shared rules, and delegation may be challenged. A proposed philosophical revision remains a candidate until the user decides its concrete meaning. +These identifiers belong to the experiment record; they add no philosophy-format fields. Keep Node scripts in Core and the outer repository free of third-party dependencies; Core owns the shared Lean implementation and evidence. Skill organization, responsibilities, shared rules, and delegation may be challenged. A proposed philosophical revision remains a candidate until the user decides its concrete meaning. ## Define the scope before implementation diff --git a/lean/README.md b/lean/README.md new file mode 100644 index 0000000..3692ff7 --- /dev/null +++ b/lean/README.md @@ -0,0 +1,14 @@ +# Lean delivery moved into Core + +The maintained implementation, evidence and bilingual reader editions now live in [OrganonCore/lean](../OrganonCore/lean/README.md). Core can run independently. This directory retains the migration entry rather than a second current evidence package. + +The outer [formalization skill](../skills/organon-leanify-prove/SKILL.md), [translation skill](../skills/organon-lean-natural-language/SKILL.md), checker CLI and programmatic exports remain compatible forwarding entrypoints. Workspace-baseline resolution stays in AgentOrganon; explicit selection and lookup failures are passed through rather than replaced with Core defaults. + +From the outer repository root, for example: + +```sh +rtk proxy node skills/organon-leanify-prove/scripts/check.js OrganonCore/lean/core-v0.1.3/philosophy --manuscript manuscript.json +rtk proxy node skills/organon-leanify-prove/scripts/check.js OrganonCore/lean/core-v0.1.3/rationale-core-tools --manuscript manuscript.json +``` + +Caller-owned run paths remain explicit; paths are not silently redirected. See [current migration validation](../OrganonCore/lean/VALIDATION.md) and [the preceding historical validation](../OrganonCore/lean/history/reader-validation-before-migration.md). Historical frozen inputs and judgments are preserved inside Core; a replayable old theorem is not current-source approval. diff --git a/lean/VALIDATION.md b/lean/VALIDATION.md new file mode 100644 index 0000000..60518e1 --- /dev/null +++ b/lean/VALIDATION.md @@ -0,0 +1,3 @@ +# Lean validation + +Current results are maintained in [Core migration validation](../OrganonCore/lean/VALIDATION.md). The [preceding reader validation](../OrganonCore/lean/history/reader-validation-before-migration.md) is historical. diff --git a/scripts/lib/frontmatter.js b/scripts/lib/frontmatter.js index ab1255b..60e52ea 100644 --- a/scripts/lib/frontmatter.js +++ b/scripts/lib/frontmatter.js @@ -1,85 +1,7 @@ -export const FORMAT_VERSION = '0.1.0'; -export const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; -export const HASH = /^[a-f0-9]{64}$/; - -export function normalize(text) { - return text.replace(/\r\n?/g, '\n'); -} - -function scalar(value) { - if (value.startsWith('"')) { - let parsed; - try { parsed = JSON.parse(value); } catch { throw new Error('Invalid quoted frontmatter scalar'); } - if (typeof parsed !== 'string') throw new Error('Expected a string scalar'); - return parsed; - } - if (SEMVER.test(value)) return value; - throw new Error('Unsupported frontmatter syntax: use X.Y.Z or a JSON double-quoted string'); -} - -export function validateMetadata(meta, supported = true) { - for (const key of ['format_version', 'philosophy_version', 'core_version']) { - if (!SEMVER.test(meta[key] ?? '')) throw new Error(`Invalid ${key}: expected X.Y.Z`); - } - if (supported && meta.format_version !== FORMAT_VERSION) { - throw new Error(`Unsupported format_version ${meta.format_version}; supported: ${FORMAT_VERSION}`); - } - if (meta.derived_from !== null) { - const source = meta.derived_from; - if (!source || Object.keys(source).sort().join(',') !== 'content_hash,philosophy_version,source_id' - || typeof source.source_id !== 'string' || !source.source_id.trim() - || !SEMVER.test(source.philosophy_version ?? '') || !HASH.test(source.content_hash ?? '')) { - throw new Error('Invalid derived_from: expected source_id, philosophy_version and content_hash'); - } - } - return meta; -} +import fs from 'node:fs'; +import { SEMVER } from '../../OrganonCore/scripts/lib/frontmatter.js'; -export function parseFrontmatter(raw, { supported = true } = {}) { - const text = normalize(raw); - if (!text.startsWith('---\n')) throw new Error('Missing philosophy frontmatter'); - const end = text.indexOf('\n---\n', 4); - if (end < 0) throw new Error('Unclosed philosophy frontmatter'); - const lines = text.slice(4, end).split('\n'); - const meta = {}; - for (let index = 0; index < lines.length; index++) { - const match = /^(format_version|philosophy_version|core_version|derived_from):(?: (.*))?$/.exec(lines[index]); - if (!match) throw new Error(`Unsupported frontmatter field or syntax: ${lines[index]}`); - const [, key, value] = match; - if (Object.hasOwn(meta, key)) throw new Error(`Duplicate frontmatter field: ${key}`); - if (key !== 'derived_from') { - if (!SEMVER.test(value ?? '')) throw new Error(`Invalid ${key}: use an unquoted X.Y.Z triple`); - meta[key] = value; - } else if (value === 'null') { - meta[key] = null; - } else if (value === undefined || value === '') { - const source = {}; - while (index + 1 < lines.length && lines[index + 1].startsWith(' ')) { - const child = /^ (source_id|philosophy_version|content_hash): (.+)$/.exec(lines[++index]); - if (!child || Object.hasOwn(source, child[1])) throw new Error('Unsupported or duplicate derived_from field'); - if (!child[2].startsWith('"')) throw new Error('derived_from values must be JSON double-quoted strings'); - source[child[1]] = scalar(child[2]); - } - meta[key] = source; - } else throw new Error('derived_from must be null or an indented source object'); - } - if (Object.keys(meta).length !== 4) throw new Error('Expected exactly four philosophy frontmatter fields'); - validateMetadata(meta, supported); - return { metadata: meta, body: text.slice(end + 5) }; -} - -export function renderFrontmatter(metadata, body) { - validateMetadata(metadata); - let header = ['---', ...['format_version', 'philosophy_version', 'core_version'].map(key => `${key}: ${metadata[key]}`)]; - if (metadata.derived_from === null) header.push('derived_from: null'); - else { - header.push('derived_from:'); - for (const key of ['source_id', 'philosophy_version', 'content_hash']) { - header.push(` ${key}: ${JSON.stringify(metadata.derived_from[key])}`); - } - } - return `${header.join('\n')}\n---\n${body}`; -} +export * from '../../OrganonCore/scripts/lib/frontmatter.js'; export function coreWarnings(version, range = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).organon.coreVersion) { const bounds = /^>=(\d+\.\d+\.\d+) <(\d+\.\d+\.\d+)$/.exec(range); @@ -93,4 +15,3 @@ export function coreWarnings(version, range = JSON.parse(fs.readFileSync(new URL return compare(version, bounds[1]) >= 0 && compare(version, bounds[2]) < 0 ? [] : [`Reviewed Core version ${version} is outside ${range}; this is a source-version warning, not a philosophical judgment.`]; } -import fs from 'node:fs'; diff --git a/scripts/lib/sections.js b/scripts/lib/sections.js index 59235a6..df465ba 100644 --- a/scripts/lib/sections.js +++ b/scripts/lib/sections.js @@ -1,64 +1 @@ -import { createHash } from 'node:crypto'; -import { normalize, parseFrontmatter } from './frontmatter.js'; - -export const ID = /^(?:extensions|[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)$/; -export const hash = text => createHash('sha256').update(text).digest('hex'); - -export function parseDocument(raw) { - const { metadata, body } = parseFrontmatter(raw); - const lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? []; - const heads = []; - let fence = null; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].replace(/\n$/, ''); - if (fence) { - if (new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(line)) fence = null; - continue; - } - const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); - if (opening) { - if (opening[1][0] === '`' && opening[2].includes('`')) throw new Error('Backtick fence info must not contain backticks'); - fence = { char: opening[1][0], length: opening[1].length }; continue; - } - const heading = /^(#{1,6}) (.+)$/.exec(line); - if (heading) { - const marker = /^\n?$/.exec(lines[i + 1] ?? ''); - if (!marker || !ID.test(marker[1])) throw new Error(`Missing or invalid stable ID after heading: ${line}`); - heads.push({ start: i, id: marker[1], level: heading[1].length, title: heading[2] }); - i++; - } else if (/organon:id/.test(line) && /^\s*\n/gm, '')), '7bd696f61572c63a79f58c2ba5f44673dc032b4fc9b6142f39991ce8f03a087a'); - assert.equal(hash(chinese.body.replace(/^\n/gm, '')), 'e4b07abafb9bace546a4a193e49e75b5f3a6f0c9a40fe261a921517bd946f24b'); + // Core 0.1.3 after the approved Assessment clarification; change only with a reviewed text revision. + assert.equal(hash(english.body.replace(/^\n/gm, '')), '41496fa241aa3c3cb6e3abc83992f904f4cbc4555f6fee935b72fc799fe06ee1'); + assert.equal(hash(chinese.body.replace(/^\n/gm, '')), '27d81360a31a44fa207bcee48d08b09da18d2f0d58beb592879b779ac5ad9cf5'); assert.equal(check(CORE_FILE, { source: true }).mode, 'source'); assert.equal(check(path.join(ROOT, 'PHILOSOPHY.md'), { source: true }).realPath, fs.realpathSync(CORE_FILE)); assert.equal(fs.existsSync(path.join(ROOT, 'PHILOSOPHY.lock.json')), false); diff --git a/zh/README.md b/zh/README.md index 231ba64..edbe312 100644 --- a/zh/README.md +++ b/zh/README.md @@ -13,6 +13,8 @@ AgentOrganon 在 [OrganonCore](../OrganonCore/README.md) 外提供工作区技 | [organon-principled-review](../skills/organon-principled-review/SKILL.md) | 按 Core 方法规定的触发条件和标准委托完整分析。 | | [organon-wording-review](../skills/organon-wording-review/SKILL.md) | 审查措辞;不要求哲学文件。 | | [organon-philosophy](../skills/organon-philosophy/SKILL.md) | 初始化、检查、导出、导入和合并采用副本。 | +| [organon-leanify-prove](../skills/organon-leanify-prove/SKILL.md) | 在以 Lean 证明和反模型作为哲学主张的证据前,审查来源保真。 | +| [organon-lean-natural-language](../skills/organon-lean-natural-language/SKILL.md) | 生成依据编码的回译与原文–Lean 对照稿,可选逐行解释。 | 前三个技能使用显式指定的哲学路径;未指定时,依次在调用工作区的 Git 根目录、当前目录查找 `PHILOSOPHY.md`,不搜索子目录。显式路径无效即停止;默认候选均缺失时提示初始化。它们不会回退到随附的 Core 哲学。委托过程中始终以所选路径为评估基准,并分别标明待采用修订与 Core 方法约束。 @@ -49,6 +51,8 @@ node --test 通过 `organon-philosophy merge --dry-run` 请求只读差异报告。技能调用 `classify.js`;不存在已安装的 `organon-philosophy` 可执行命令。技能文档说明候选准备、决定、恢复及应用命令。报告、候选、计划和六段合并记录保存在已忽略的 `.local/iterations/merges/`。准备好的计划绑定已审视输入和候选;输入过期则停止应用。完成准备或通过文件检查不提供采用授权。 +通过链接的入口调用 Lean 技能;它们是 agent 指令,不是已安装的可执行程序。证明流程使用已安装的 Lean `v4.33.1`,仅使用核心库;[运行格式](../skills/organon-leanify-prove/references/run-format.md) 说明工程结构与 `node skills/organon-leanify-prove/scripts/check.js ` 检查器。机械通过不证明来源保真。翻译技能也可在没有原文时独立使用;通过 `--explain-lines` 请求详解附录。私有证明运行保留在已忽略的 `.local/`。 + 私有包名为 `@shendeguize/agent-organon`,版本为 `0.1.0`。安装模式和发布不在本次实现范围内。贡献前请阅读 [AGENTS.md](../AGENTS.md);远程发布遵循其引用的 Core 授权规则。 使用[自实践与迭代协议](docs/self-iteration.md)比较方法、保留证据并完成有界迭代。 @@ -56,3 +60,5 @@ node --test ## 许可证 MIT。 + +两个 Lean 技能的实现、检查器及当前证据已由 [OrganonCore](../OrganonCore/lean/README.md) 维护。外层入口保留工作区基线解析并转交对应 Core 技能;既有检查命令保持兼容。 diff --git a/zh/docs/self-iteration.md b/zh/docs/self-iteration.md index 4180886..73ad419 100644 --- a/zh/docs/self-iteration.md +++ b/zh/docs/self-iteration.md @@ -10,7 +10,7 @@ - **F0 / C0:**已采用的哲学和本轮评价标准。在候选实现前明确适用性、假设、术语、预期可观察结果及重要退步。 - **M0 / M1…:**当前方法和候选方法版本。将所选方法与固定的 R0 对象分别提供;即使方法自身的文本是审查对象,也保留此区分。 -这些标识属于实验记录,不新增哲学格式字段。保持 Core 纯文本及外层 Node 脚本无第三方依赖。技能组织、职责、共享规则和委托方式均可受到质疑。哲学修订在用户决定其具体含义前始终是候选。 +这些标识属于实验记录,不新增哲学格式字段。保持 Core 与外层 Node 脚本无第三方依赖;Core 维护共享的 Lean 实现及证据。技能组织、职责、共享规则和委托方式均可受到质疑。哲学修订在用户决定其具体含义前始终是候选。 ## 在实现前明确范围 From 98b93e18c1b7e2a332dfadb79295b751f2736530 Mon Sep 17 00:00:00 2001 From: YANG Jingyu Date: Tue, 15 Sep 2026 13:23:33 +0800 Subject: [PATCH 3/5] build: freeze shared release tooling --- .gitmodules | 3 + AGENTS.md | 4 +- AdvisedOrganons | 1 + OrganonCore | 2 +- README.md | 79 +++--- assets/banner.svg | 1 + docs/getting-started.md | 47 ++++ docs/understanding.md | 38 +++ docs/workspace-reference.md | 34 +++ installer/lib/adapters.mjs | 13 + installer/lib/archive.mjs | 70 ++++++ installer/lib/files.mjs | 40 +++ installer/lib/lifecycle.mjs | 277 +++++++++++++++++++++ installer/lib/package.mjs | 71 ++++++ installer/lib/transaction.mjs | 64 +++++ installer/organon.mjs | 35 +++ lean/README.md | 6 +- lean/VALIDATION.md | 2 +- maintenance/pages-and-stars.md | 27 ++ maintenance/release.md | 44 ++++ {docs => maintenance}/self-iteration.md | 4 +- package.json | 32 ++- release/agents.json | 63 +++++ scripts/agents/README.md | 37 +++ scripts/agents/core.mjs | 123 +++++++++ scripts/agents/review.mjs | 74 ++++++ scripts/agents/run.mjs | 128 ++++++++++ scripts/agents/setup.mjs | 49 ++++ scripts/agents/verify-candidate.mjs | 36 +++ scripts/package/build.mjs | 123 +++++++++ scripts/package/check.mjs | 47 ++++ scripts/release/actions-artifacts.mjs | 46 ++++ scripts/release/assemble.mjs | 28 +++ scripts/release/candidate.mjs | 53 ++++ scripts/release/collect.mjs | 104 ++++++++ scripts/release/content.mjs | 45 ++++ scripts/release/evidence.mjs | 137 ++++++++++ scripts/release/governance.mjs | 56 +++++ scripts/release/install-matrix.mjs | 26 ++ scripts/release/lib.mjs | 57 +++++ scripts/release/manifest.mjs | 111 +++++++++ scripts/release/publish.mjs | 153 ++++++++++++ scripts/release/security.mjs | 58 +++++ scripts/release/site-data.mjs | 163 ++++++++++++ scripts/release/site.mjs | 14 ++ scripts/release/stable.mjs | 191 ++++++++++++++ scripts/release/stars.mjs | 61 +++++ scripts/release/tooling.mjs | 18 ++ scripts/test.mjs | 12 + site/index.md | 35 +++ site/lean.md | 10 + site/philosophy.md | 9 + site/public/assets/stars.json | 10 + site/public/assets/stars.svg | 1 + site/rationale.md | 6 + site/site.json | 7 + site/zh/index.md | 35 +++ site/zh/lean.md | 10 + site/zh/philosophy.md | 9 + site/zh/rationale.md | 6 + skills/organon-absorb/SKILL.md | 2 +- tests/agents-harness.test.mjs | 163 ++++++++++++ tests/installer.test.mjs | 254 +++++++++++++++++++ tests/management.test.js | 6 +- tests/package.test.mjs | 69 +++++ tests/release-attempts.test.mjs | 41 +++ tests/release-evidence.test.mjs | 58 +++++ tests/release-site-data.test.mjs | 85 +++++++ tests/release-stable.test.mjs | 138 ++++++++++ tests/release-trusted.test.mjs | 109 ++++++++ tests/release.test.mjs | 78 ++++++ zh/AGENTS.md | 4 +- zh/README.md | 79 +++--- zh/docs/getting-started.md | 47 ++++ zh/docs/understanding.md | 38 +++ zh/docs/workspace-reference.md | 34 +++ zh/maintenance/pages-and-stars.md | 27 ++ zh/maintenance/release.md | 44 ++++ zh/{docs => maintenance}/self-iteration.md | 4 +- 79 files changed, 4224 insertions(+), 101 deletions(-) create mode 160000 AdvisedOrganons create mode 100644 assets/banner.svg create mode 100644 docs/getting-started.md create mode 100644 docs/understanding.md create mode 100644 docs/workspace-reference.md create mode 100644 installer/lib/adapters.mjs create mode 100644 installer/lib/archive.mjs create mode 100644 installer/lib/files.mjs create mode 100644 installer/lib/lifecycle.mjs create mode 100644 installer/lib/package.mjs create mode 100644 installer/lib/transaction.mjs create mode 100644 installer/organon.mjs create mode 100644 maintenance/pages-and-stars.md create mode 100644 maintenance/release.md rename {docs => maintenance}/self-iteration.md (90%) create mode 100644 release/agents.json create mode 100644 scripts/agents/README.md create mode 100644 scripts/agents/core.mjs create mode 100644 scripts/agents/review.mjs create mode 100644 scripts/agents/run.mjs create mode 100644 scripts/agents/setup.mjs create mode 100644 scripts/agents/verify-candidate.mjs create mode 100644 scripts/package/build.mjs create mode 100644 scripts/package/check.mjs create mode 100644 scripts/release/actions-artifacts.mjs create mode 100644 scripts/release/assemble.mjs create mode 100644 scripts/release/candidate.mjs create mode 100644 scripts/release/collect.mjs create mode 100644 scripts/release/content.mjs create mode 100644 scripts/release/evidence.mjs create mode 100644 scripts/release/governance.mjs create mode 100644 scripts/release/install-matrix.mjs create mode 100644 scripts/release/lib.mjs create mode 100644 scripts/release/manifest.mjs create mode 100644 scripts/release/publish.mjs create mode 100644 scripts/release/security.mjs create mode 100644 scripts/release/site-data.mjs create mode 100644 scripts/release/site.mjs create mode 100644 scripts/release/stable.mjs create mode 100644 scripts/release/stars.mjs create mode 100644 scripts/release/tooling.mjs create mode 100644 scripts/test.mjs create mode 100644 site/index.md create mode 100644 site/lean.md create mode 100644 site/philosophy.md create mode 100644 site/public/assets/stars.json create mode 100644 site/public/assets/stars.svg create mode 100644 site/rationale.md create mode 100644 site/site.json create mode 100644 site/zh/index.md create mode 100644 site/zh/lean.md create mode 100644 site/zh/philosophy.md create mode 100644 site/zh/rationale.md create mode 100644 tests/agents-harness.test.mjs create mode 100644 tests/installer.test.mjs create mode 100644 tests/package.test.mjs create mode 100644 tests/release-attempts.test.mjs create mode 100644 tests/release-evidence.test.mjs create mode 100644 tests/release-site-data.test.mjs create mode 100644 tests/release-stable.test.mjs create mode 100644 tests/release-trusted.test.mjs create mode 100644 tests/release.test.mjs create mode 100644 zh/docs/getting-started.md create mode 100644 zh/docs/understanding.md create mode 100644 zh/docs/workspace-reference.md create mode 100644 zh/maintenance/pages-and-stars.md create mode 100644 zh/maintenance/release.md rename zh/{docs => maintenance}/self-iteration.md (89%) diff --git a/.gitmodules b/.gitmodules index eb54b4b..d67d6aa 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "OrganonCore"] path = OrganonCore url = https://github.com/shendeguize/OrganonCore +[submodule "AdvisedOrganons"] + path = AdvisedOrganons + url = https://github.com/shendeguize/AdvisedOrganons.git diff --git a/AGENTS.md b/AGENTS.md index f8bd3b1..a07c549 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Working on AgentOrganon -Read [OrganonCore/AGENTS.md](OrganonCore/AGENTS.md) together with this file, including its linked philosophy, review methods, and [iteration rules](OrganonCore/skills/references/iteration.md). Apply the shared maintenance, independent review, wording, authorization, record, and stopping rules to outer repository work. Core owns Lean implementations, checking tools and shared pure parsing; the outer repository supplies philosophy-management scripts and compatible Lean forwarding entrypoints. Node tools in both repositories have no third-party dependencies. +Read [OrganonCore/AGENTS.md](OrganonCore/AGENTS.md) together with this file, including its linked philosophy, review methods, and [iteration rules](OrganonCore/skills/references/iteration.md). Apply the shared maintenance, independent review, wording, authorization, record, and stopping rules to outer repository work. Core owns Lean implementations, checking tools and shared pure parsing; the outer repository supplies philosophy-management scripts and compatible Lean forwarding entrypoints. Product runtime tools in both repositories have no third-party runtime dependencies. Site building and validation may use locked third-party dependencies, which are excluded from installation packages. Keep the adopted workspace philosophy, a proposed replacement, and the Core method's constraints distinct. Use the [philosophy resolution contract](OrganonCore/skills/references/philosophy-resolution.md) and [format contract](OrganonCore/skills/references/structure.md). Outer skills pass the selected baseline through delegation. A difference from Core is not automatically a contradiction within an adopter's commitments. Compatibility alone does not warrant adoption, and conflict alone does not defeat revision reasons. @@ -10,4 +10,6 @@ Scripts perform deterministic parsing, comparison, and file operations. Agents a Keep changes within the authorized scope and preserve existing user work. Retain private evidence and the shared six-part records under ignored `.local/`; use `.local/iterations/merges/` for merge reports, candidates, decisions, plans, and records. Verify that the directory is ignored before writing records. English documentation is authoritative for its translations; follow [zh/AGENTS.md](zh/AGENTS.md) when maintaining the translation pair. Do not translate SKILL.md files as part of this implementation. +For local records, start with `.local/ACTIVE.md` in the relevant repository. Keep feedback records active. Exclude `.local/archived/` from default recursive searches and do not open archive packages by default. When the task explicitly requires historical material, consult `.local/archived/INDEX.md` and retrieve only the relevant packages or members, following their recorded dependencies and restoration conditions. Archived decisions are historical evidence, not active obligations; later corrections belong in separate records linked to the originals. + Remote publication follows [Core's authorization rules](OrganonCore/AGENTS.md#remote-publication-authorization). Reuse authorization for its stated action, destination, and scope. An implementation request does not itself authorize a push. Do not publish an outer revision that references an unavailable Core commit. diff --git a/AdvisedOrganons b/AdvisedOrganons new file mode 160000 index 0000000..c0ff2ec --- /dev/null +++ b/AdvisedOrganons @@ -0,0 +1 @@ +Subproject commit c0ff2ec971a56fd607f51bcca3041bd40c6377b8 diff --git a/OrganonCore b/OrganonCore index b3631f9..3cca824 160000 --- a/OrganonCore +++ b/OrganonCore @@ -1 +1 @@ -Subproject commit b3631f9a13f3b2200524fe0bbc5d167b22c4e982 +Subproject commit 3cca8240817b6d2917c6969e01b1689bde6c883e diff --git a/README.md b/README.md index ad5f401..a2dff61 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,59 @@ -# AgentOrganon +![AgentOrganon](assets/banner.svg) -AgentOrganon supplies workspace skills and deterministic file operations around [OrganonCore](OrganonCore/README.md). Core contains the philosophy and review methods; this outer repository contains the Node scripts that manage adopted copies. The adopting workspace may revise or withdraw commitments with an explicit user decision. +[English](README.md) · [简体中文](zh/README.md) · [Website](https://shendeguize.github.io/AgentOrganon/) · [Releases](https://github.com/shendeguize/AgentOrganon/releases) -English is the maintained source for this repository's documentation. See the [Chinese translation](zh/README.md). +# AgentOrganon -## Skills +Workspace methods and philosophy management. -| Skill | Responsibility | -| --- | --- | -| [organon-assess](skills/organon-assess/SKILL.md) | Assess an input against the selected workspace philosophy. | -| [organon-absorb](skills/organon-absorb/SKILL.md) | Examine reasons for revising that philosophy and implement authorized adoption after review. | -| [organon-principled-review](skills/organon-principled-review/SKILL.md) | Delegate full analysis under the Core method's stated triggers and standards. | -| [organon-wording-review](skills/organon-wording-review/SKILL.md) | Review wording; no philosophy file is required. | -| [organon-philosophy](skills/organon-philosophy/SKILL.md) | Initialize, check, export, import, and merge adopted copies. | -| [organon-leanify-prove](skills/organon-leanify-prove/SKILL.md) | Review source fidelity before using Lean proofs and countermodels as evidence about philosophical claims. | -| [organon-lean-natural-language](skills/organon-lean-natural-language/SKILL.md) | Produce code-derived backtranslations and source–Lean manuscripts, with optional line explanations. | +## Design Aim · Ground agent judgments and improvements -The first three skills use an explicitly supplied philosophy path, or look for `PHILOSOPHY.md` first at the calling workspace's Git root and then in its current directory. They do not search descendants. An invalid explicit path stops the operation; missing default candidates prompt initialization. They never fall back to the bundled Core philosophy. The selected path remains the assessment baseline through delegation; proposed revisions and Core method constraints are identified separately. +Make commitments, reasons and boundaries readable, assessable and revisable. Organon supplies philosophy and methods; it does not promise correct judgments or automatic improvement. Philosophical adoption retains an explicit human decision. -Compatibility does not establish a reason for adoption, and conflict with an existing commitment does not by itself defeat reasons to revise it. Scripts check and transform files. Agents and users assess meaning, review the candidate as a whole, and decide adoption. +## Start here -## Files and versions +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 install --product agent-organon --agent codex --scope project --project /path/to/workspace --version 1.0.0-rc.1 +``` -An adopting workspace uses a regular `PHILOSOPHY.md` and adjacent `PHILOSOPHY.lock.json`, whose `document_filename` identifies the managed file in that directory. The [format contract](OrganonCore/skills/references/structure.md) defines the four supported frontmatter fields and stable IDs for every heading with its direct body and for introductory text. It supports unindented ATX headings such as `## Title`; other ATX forms and Setext headings are rejected. The current three-chapter Core organization is an initialization template; adopters may reorganize it. Initialization adds an empty Extensions section, identified by stable ID `extensions` rather than its title or position. +Release candidate **1.0.0-rc.1** awaits review. Run this command once the public package is available; before publication use a local candidate archive. Requires Node.js 22+. Project and global installation are available; the release matrix records actual validation for the six agent adapters. -- `format_version` identifies the supported file format. Unsupported formats stop managed writes. -- `philosophy_version` describes semantic revision, proposed by an agent and decided by the user: changed or withdrawn commitments, meanings, or applicability require major; additions preserving existing commitments require minor; meaning-preserving wording or structural maintenance requires patch. -- `core_version` records the last fully reviewed Core source version. A version outside `package.json`'s `organon.coreVersion` range produces a source-version warning, not a philosophical verdict. -- `derived_from` records export provenance. Neither provenance nor matching versions establishes a common ancestor. +[Complete quick start: install → check → select philosophy → read-only assessment](https://shendeguize.github.io/AgentOrganon/quick-start) -The lock stores hashes and structure for reviewed source checkpoints, plus remembered declines. It contains no old source text. Local differences from a checkpoint are expected. A declined source unit is not proposed again until its incoming state changes; the actual difference remains visible. Imports with no verifiable source checkpoint use two-way differences and never advance Core's reviewed version. +## Read and navigate -This repository's root `PHILOSOPHY.md` is a relative symlink to `OrganonCore/PHILOSOPHY.md`, used only for reading; relative references resolve from the real source directory. There is no root derived lock. Management scripts reject writes through or over this symlink and writes to the Core source. Core changes use its explicit maintenance or absorption workflow. +| Entry | Content | +| --- | --- | +| [AgentOrganon](https://github.com/shendeguize/AgentOrganon) | Workspace skills and management of adopted copies. | +| [OrganonCore](https://github.com/shendeguize/OrganonCore) | Core philosophy, review methods and Lean evidence. | +| [AdvisedOrganons](https://github.com/shendeguize/AdvisedOrganons) | Domain philosophy collection; select a domain explicitly. | +| [Docs](https://shendeguize.github.io/AgentOrganon/understand) | Concepts, operations and capability boundaries for readers. | +| [Philosophy](https://shendeguize.github.io/AgentOrganon/philosophy) | Adopted commitments with their meanings and conditions. | +| [Lean](https://shendeguize.github.io/AgentOrganon/lean) | Claim overviews and paired code with line explanations. | -## Local use +Release packages contain philosophy, non-Lean skills and required runtime files. Websites, tutorials, Lean projects and evidence remain in source. Rationale is separate from reader documentation and adds no philosophical obligations; maintenance protocols live in maintenance. -Use Node.js 22; there are no third-party runtime dependencies. Run these commands from this checkout, with the OrganonCore submodule present. Use absolute script paths when working from another directory. The target's parent directory must already exist. In a Git workspace, ignore `.local/` before initialization so recovery journals remain private; the scripts create the journal directory when needed. -```sh -node scripts/check.js --source OrganonCore/PHILOSOPHY.md -node scripts/check.js --source PHILOSOPHY.md -node scripts/init.js --target /path/to/workspace/PHILOSOPHY.md -node scripts/check.js /path/to/workspace/PHILOSOPHY.md -node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/export.md -node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/core-part.md --core-only -node --test -``` +## Choose a method -Initialization and export require absent output paths. `--core-only` removes the subtree identified by `extensions`; a missing marker stops export. The remaining text is still the adopter's text, not an official Core copy. Source identities are caller-supplied labels or stored document identities, not authenticated publisher identities. Managed operations, including reads for initialization, export, and classification, stop when an input has a pending transaction; use the reported recovery command before resuming. +| Method | Purpose | +| --- | --- | +| `organon-assess` | Assess an input under the selected philosophy. | +| `organon-absorb` | Examine reasons for philosophical revision and carry out authorized adoption. | +| `organon-principled-review` | Analyze an object under the shared method’s stated standards. | +| `organon-wording-review` | Review wording without deciding philosophical adoption. | +| `organon-philosophy` | Manage philosophy copies, versions and file operations. | -Use `organon-philosophy merge --dry-run` to request a read-only difference report. The skill invokes `classify.js`; there is no installed `organon-philosophy` executable. The skill documents candidate preparation, decisions, recovery, and application commands. Keep reports, candidates, plans, and six-part merge records in ignored `.local/iterations/merges/`. A prepared plan binds the reviewed inputs and candidate; stale inputs stop application. Preparation and successful file checks do not supply adoption authorization. +## Star history -Invoke the Lean skills by their linked entrypoints. They are agent instructions, not installed executables. The proof workflow uses installed Lean `v4.33.1` and only its core library; its [run format](skills/organon-leanify-prove/references/run-format.md) describes the project and `node skills/organon-leanify-prove/scripts/check.js ` checker. Mechanical success does not certify source fidelity. The translation skill also works independently without original prose; request `--explain-lines` for a detailed appendix. Private proof runs remain in ignored `.local/`. +![Observed total stars for this repository](https://shendeguize.github.io/AgentOrganon/assets/stars.svg) -The private package name is `@shendeguize/agent-organon`, version `0.1.0`. Installation modes and release publication are outside this implementation. Read [AGENTS.md](AGENTS.md) before contributing; remote publication follows the referenced Core authorization rules. +Daily observations begin when collection is enabled. Zero totals, unstars and missing samples are retained; the chart reports its update time. -Use the [self-practice and iteration protocol](docs/self-iteration.md) to compare methods, retain evidence, and complete a bounded iteration. +Source-checkout operations: [workspace files and script reference](docs/workspace-reference.md). -## License +## Contribute -MIT. +Read the repository’s agent guidance and maintenance protocols before contributing. Mechanical checks, independent review and human adoption decisions have distinct responsibilities. -The implementation of both Lean skills, their checker and current evidence now belongs to [OrganonCore](OrganonCore/lean/README.md). The outer entrypoints retain workspace-baseline resolution and forward to the corresponding Core skills; the existing checker command remains compatible. +MIT · [License](LICENSE) diff --git a/assets/banner.svg b/assets/banner.svg new file mode 100644 index 0000000..a08f3cd --- /dev/null +++ b/assets/banner.svg @@ -0,0 +1 @@ +AgentOrganon — OrganonGround agent judgments and improvements. Philosophy, methods and grounds.PHILOSOPHY · METHODS · GROUNDSAgentOrganonGround agent judgments and improvements.ORGANON / 1.0.0-rc.1 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..703cb74 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,47 @@ +# Quick start + +Use Node.js 22 or later and your chosen agent tool. These commands target the published candidate package; before publication, use the local candidate archive route below. + +## 1. Install the methods + +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 install --product agent-organon --agent codex --scope project --project /path/to/workspace --version 1.0.0-rc.1 +``` + +Replace `/path/to/workspace` with your project path. Choose `codex`, `claude`, `cursor`, `copilot`, `gemini` or `opencode` for `--agent`. Start with project installation; use `--scope global` for global installation. Installing methods does not adopt a philosophy. + +## 2. Check installation and discovery + +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 check --product agent-organon --agent codex --scope project --project /path/to/workspace +``` + +Open the agent in that project and confirm the method appears in its skill list or can be loaded explicitly. File checks establish installation integrity; actual tool support is recorded in the candidate’s validation matrix. Resolve reported project/global naming conflicts before continuing. + +## 3. Select a philosophy explicitly + +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 init --product agent-organon --agent codex --scope project --project /path/to/workspace --philosophy core +``` + +Read the preview, confirm the destination and philosophical text, then repeat the command with `--apply --plan-sha256 ` using the exact `planSha256` value in that preview. A changed philosophy or instruction file requires a new preview and decision. An existing philosophy must not be silently replaced; the collection root does not select a domain. Method installation and project adoption are managed separately. + +## 4. Complete a read-only assessment + +Give the agent the actual material you want assessed and ask: + +> Use `organon-assess` with this project’s explicitly selected `PHILOSOPHY.md` as the adopted baseline. Assess the material’s grounds, applicability and possible conflicts. Return a read-only report; do not edit files or adopt a proposal. + +Check that the report identifies its baseline, reasons and limits. An assessment does not authorize philosophical revision; missing independent review must remain explicitly incomplete. + +## Offline archives and lifecycle commands + +Download and extract the archive from the corresponding GitHub Release. Every product includes `installer/organon.mjs`, which Node can run directly; only the AgentOrganon npm package registers the `organon` command. + +```sh +node /path/to/extracted/package/installer/organon.mjs install --product agent-organon --agent codex --scope project --project /path/to/workspace --from /path/to/shendeguize-agent-organon-1.0.0-rc.1.tgz +``` + +The same entrypoint provides `update`, `rollback` and `uninstall`. Check integrity before updating; user modifications or existing-file conflicts stop an update. Uninstall retains user modifications and the adopted project philosophy. + +[Understand the concepts and boundaries](https://shendeguize.github.io/AgentOrganon/understand) · [GitHub Releases](https://github.com/shendeguize/AgentOrganon/releases) diff --git a/docs/understanding.md b/docs/understanding.md new file mode 100644 index 0000000..cb4a8a1 --- /dev/null +++ b/docs/understanding.md @@ -0,0 +1,38 @@ +# Understand Organon + +## Start with three distinct objects + +**Philosophy** states adopted commitments, their meanings and conditions of application. **Skills** apply methods to tasks and remain revisable. **Tools** check and transform files; they do not decide philosophical correctness or reasons for adoption. + +## How an assessment starts + +Identify the actual input and adopted philosophy, then state the question to assess. Examine reasons, conditions and scope; distinguish contradiction from insufficient support. When considering a revision, keep the preceding baseline identifiable. Compatibility does not warrant adoption, and conflict does not by itself defeat reasons for revision. + +## When change is needed + +An ordinary assessment can end with a report. Absorption examines reasons for philosophical revision and implements it after explicit authorization. Wording review concerns expression; file management checks structure and identity. These methods have different responsibilities and do not replace one another’s judgments. + +## A map of the reading material + +- [Philosophy](https://shendeguize.github.io/AgentOrganon/philosophy): commitments, meanings and limits. +- [Rationale](https://shendeguize.github.io/AgentOrganon/rationale): arguments, alternatives and objections; no additional philosophical obligations. +- [Lean reader](https://shendeguize.github.io/AgentOrganon/lean): begin with claims, premises and boundaries, then inspect declarations and line explanations. +- Maintenance protocols: review, validation and release procedures for maintainers, in the repository’s `maintenance/` directory. + +## What Lean can establish + +Lean checks formal conclusions under given definitions and assumptions. Target `accepted`, kernel `passed` and source fidelity are different judgments; `limited`, `incomplete` and `not_applicable` retain their recorded meanings. Defining a duty does not show its fulfillment; a finite model does not establish universal real-world correctness. Readers unfamiliar with Lean can start with the claim overview and consult the paired code and explanation pages one line at a time. + +## Installation and adoption are separate + +Project installation supplies method entrypoints for that project; global installation supplies user-level entrypoints. Neither adopts a philosophy automatically. A project selects its baseline through explicit initialization; updating, rolling back or uninstalling methods does not automatically change that decision. A domain collection requires an explicit domain choice. + +## Choose a method + +| Method | Purpose | +| --- | --- | +| `organon-assess` | Assess an input under the selected philosophy. | +| `organon-absorb` | Examine reasons for philosophical revision and carry out authorized adoption. | +| `organon-principled-review` | Analyze an object under the shared method’s stated standards. | +| `organon-wording-review` | Review wording without deciding philosophical adoption. | +| `organon-philosophy` | Manage philosophy copies, versions and file operations. | diff --git a/docs/workspace-reference.md b/docs/workspace-reference.md new file mode 100644 index 0000000..b903b08 --- /dev/null +++ b/docs/workspace-reference.md @@ -0,0 +1,34 @@ +# Workspace files and scripts reference + +## Files and versions + +An adopting workspace uses a regular `PHILOSOPHY.md` and adjacent `PHILOSOPHY.lock.json`, whose `document_filename` identifies the managed file in that directory. The [format contract](../OrganonCore/skills/references/structure.md) defines the four supported frontmatter fields and stable IDs for every heading with its direct body and for introductory text. It supports unindented ATX headings such as `## Title`; other ATX forms and Setext headings are rejected. The current three-chapter Core organization is an initialization template; adopters may reorganize it. Initialization adds an empty Extensions section, identified by stable ID `extensions` rather than its title or position. + +- `format_version` identifies the supported file format. Unsupported formats stop managed writes. +- `philosophy_version` describes semantic revision, proposed by an agent and decided by the user: changed or withdrawn commitments, meanings, or applicability require major; additions preserving existing commitments require minor; meaning-preserving wording or structural maintenance requires patch. +- `core_version` records the last fully reviewed Core source version. A version outside `package.json`'s `organon.coreVersion` range produces a source-version warning, not a philosophical verdict. +- `derived_from` records export provenance. Neither provenance nor matching versions establishes a common ancestor. + +The lock stores hashes and structure for reviewed source checkpoints, plus remembered declines. It contains no old source text. Local differences from a checkpoint are expected. A declined source unit is not proposed again until its incoming state changes; the actual difference remains visible. Imports with no verifiable source checkpoint use two-way differences and never advance Core's reviewed version. + +This repository's root `PHILOSOPHY.md` is a relative symlink to `OrganonCore/PHILOSOPHY.md`, used only for reading; relative references resolve from the real source directory. There is no root derived lock. Management scripts reject writes through or over this symlink and writes to the Core source. Core changes use its explicit maintenance or absorption workflow. + +## Local use + +Use Node.js 22; there are no third-party runtime dependencies. Run these commands from this checkout, with the OrganonCore submodule present. Use absolute script paths when working from another directory. The target's parent directory must already exist. In a Git workspace, ignore `.local/` before initialization so recovery journals remain private; the scripts create the journal directory when needed. + +```sh +node scripts/check.js --source OrganonCore/PHILOSOPHY.md +node scripts/check.js --source PHILOSOPHY.md +node scripts/init.js --target /path/to/workspace/PHILOSOPHY.md +node scripts/check.js /path/to/workspace/PHILOSOPHY.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/export.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/core-part.md --core-only +node --test tests/*.test.js +``` + +Initialization and export require absent output paths. `--core-only` removes the subtree identified by `extensions`; a missing marker stops export. The remaining text is still the adopter's text, not an official Core copy. Source identities are caller-supplied labels or stored document identities, not authenticated publisher identities. Managed operations, including reads for initialization, export, and classification, stop when an input has a pending transaction; use the reported recovery command before resuming. + +Use `organon-philosophy merge --dry-run` to request a read-only difference report. The skill invokes `classify.js`; there is no installed `organon-philosophy` executable. The skill documents candidate preparation, decisions, recovery, and application commands. Keep reports, candidates, plans, and six-part merge records in ignored `.local/iterations/merges/`. A prepared plan binds the reviewed inputs and candidate; stale inputs stop application. Preparation and successful file checks do not supply adoption authorization. + +[Maintenance and iteration protocol](../maintenance/self-iteration.md) diff --git a/installer/lib/adapters.mjs b/installer/lib/adapters.mjs new file mode 100644 index 0000000..01af3c9 --- /dev/null +++ b/installer/lib/adapters.mjs @@ -0,0 +1,13 @@ +import path from 'node:path'; + +// Native discovery paths, checked against the linked vendor documentation. +// Filesystem installation does not certify authenticated invocation/delegation. +export const AGENTS = { + codex: { project: '.agents/skills', global: '.agents/skills', instructions: 'AGENTS.md', aliases: ['.codex/skills'], command: 'codex', source: 'https://developers.openai.com/codex/skills/' }, + claude: { project: '.claude/skills', global: '.claude/skills', instructions: 'CLAUDE.md', aliases: [], command: 'claude', source: 'https://code.claude.com/docs/en/skills' }, + cursor: { project: '.cursor/skills', global: '.cursor/skills', instructions: 'AGENTS.md', aliases: ['.agents/skills', '.claude/skills'], command: 'agent', source: 'https://cursor.com/docs/skills' }, + copilot: { project: '.github/skills', global: '.copilot/skills', instructions: 'AGENTS.md', aliases: ['.agents/skills', '.claude/skills'], command: 'copilot', source: 'https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference' }, + gemini: { project: '.gemini/skills', global: '.gemini/skills', instructions: 'GEMINI.md', aliases: ['.agents/skills'], command: 'gemini', source: 'https://geminicli.com/docs/cli/skills/' }, + opencode: { project: '.opencode/skills', global: '.config/opencode/skills', instructions: 'AGENTS.md', aliases: ['.agents/skills', '.claude/skills'], command: 'opencode', source: 'https://opencode.ai/docs/skills/' }, +}; +export function discoveryRoot(agent, scope, root) { return path.join(root, AGENTS[agent][scope]); } diff --git a/installer/lib/archive.mjs b/installer/lib/archive.mjs new file mode 100644 index 0000000..29e0518 --- /dev/null +++ b/installer/lib/archive.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { listFiles, safeRelative, write } from './files.mjs'; + +// A small regular-file-only ustar implementation. Archives never create links, +// devices or executable lifecycle hooks. Reject extension records and duplicates. +const text = (buffer, start, length) => buffer.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, ''); +function octal(buffer, start, length) { + const value = text(buffer, start, length).trim(); + if (!/^[0-7]*$/.test(value)) throw new Error('Invalid tar numeric field'); + return parseInt(value || '0', 8); +} +export function extractTarball(file, destination) { + const archive = gunzipSync(fs.readFileSync(file), { maxOutputLength: 64 * 1024 * 1024 }); + const entries = []; + const names = new Set(); + let offset = 0; + for (; offset + 512 <= archive.length; ) { + const header = archive.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) break; + const expected = octal(header, 148, 8); + const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0); + if (sum !== expected) throw new Error('Invalid tar checksum'); + const prefix = text(header, 345, 155); + const rawName = `${prefix ? `${prefix}/` : ''}${text(header, 0, 100)}`; + const type = text(header, 156, 1); + if (!['', '0', '5'].includes(type)) throw new Error(`Unsupported tar entry type: ${type}`); + const name = safeRelative(rawName.replace(/\/$/, '')); + if (name !== 'package' && !name.startsWith('package/')) throw new Error('Archive must use a package/ root'); + if (names.has(name)) throw new Error(`Duplicate tar entry: ${name}`); + names.add(name); + const size = octal(header, 124, 12); + offset += 512; + if (offset + size > archive.length) throw new Error('Truncated tar entry'); + if (type === '5' && size !== 0) throw new Error('Tar directory has content'); + if (type !== '5') entries.push([name, archive.subarray(offset, offset + size)]); + offset += Math.ceil(size / 512) * 512; + } + if (archive.subarray(offset).some(byte => byte !== 0)) throw new Error('Invalid tar trailer'); + for (const [name, bytes] of entries) write(path.join(destination, name), bytes); + return path.join(destination, 'package'); +} +export function createTarball(root, destination) { + const chunks = []; + for (const name of listFiles(root)) { + const full = `package/${name}`; + let basename = full, prefix = ''; + if (Buffer.byteLength(full) > 100) { + const split = full.lastIndexOf('/'); + prefix = full.slice(0, split); basename = full.slice(split + 1); + } + if (Buffer.byteLength(prefix) > 155 || Buffer.byteLength(basename) > 100) throw new Error(`Path exceeds ustar limits: ${full}`); + const bytes = fs.readFileSync(path.join(root, name)); + const header = Buffer.alloc(512); + const field = (value, start, length) => header.write(value, start, length, 'ascii'); + const number = (value, start, length) => field(`${value.toString(8).padStart(length - 1, '0')}\0`, start, length); + header.write(basename, 0, 100, 'utf8'); + number(name === 'installer/organon.mjs' ? 0o755 : 0o644, 100, 8); + number(0, 108, 8); number(0, 116, 8); number(bytes.length, 124, 12); number(0, 136, 12); + field(' ', 148, 8); field('0', 156, 1); field('ustar\0', 257, 6); field('00', 263, 2); + header.write(prefix, 345, 155, 'utf8'); + const sum = header.reduce((total, byte) => total + byte, 0); + field(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8); + chunks.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + chunks.push(Buffer.alloc(1024)); + write(destination, gzipSync(Buffer.concat(chunks), { level: 9 })); + return destination; +} diff --git a/installer/lib/files.mjs b/installer/lib/files.mjs new file mode 100644 index 0000000..05ce01a --- /dev/null +++ b/installer/lib/files.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +export const sha256 = value => createHash('sha256').update(value).digest('hex'); +export const json = file => JSON.parse(fs.readFileSync(file, 'utf8')); +export const exists = file => { try { fs.lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } }; +export function write(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, value); +} +export const serialize = value => `${JSON.stringify(value, null, 2)}\n`; +export function safeRelative(value) { + if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || value.split('/').some(part => !part || part === '.' || part === '..') || /^[A-Za-z]:/.test(value)) throw new Error(`Unsafe relative path: ${value}`); + return value; +} +export function within(root, file) { + const relative = path.relative(root, file); + if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) throw new Error(`Path escapes managed root: ${file}`); + return file; +} +export function noSymlinkAncestors(file) { + let cursor = path.resolve(file); + for (;;) { + if (exists(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Symbolic link destination is not writable: ${cursor}`); + const parent = path.dirname(cursor); + if (parent === cursor) return; + cursor = parent; + } +} +export function listFiles(root, prefix = '') { + const result = []; + for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const name = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) result.push(...listFiles(root, name)); + else if (entry.isFile()) result.push(name); + else throw new Error(`Unsupported file type: ${name}`); + } + return result.sort(); +} diff --git a/installer/lib/lifecycle.mjs b/installer/lib/lifecycle.mjs new file mode 100644 index 0000000..9c89e44 --- /dev/null +++ b/installer/lib/lifecycle.mjs @@ -0,0 +1,277 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { AGENTS, discoveryRoot } from './adapters.mjs'; +import { PRODUCTS, openPackage, verifyPackage } from './package.mjs'; +import { exists, json, serialize, sha256, write, within, noSymlinkAncestors, listFiles } from './files.mjs'; +import { locked, recover, transact, matches } from './transaction.mjs'; + +function context(options) { + if (!Object.hasOwn(PRODUCTS, options.product)) throw new Error('--product agent-organon|core|advised is required'); + if (!Object.hasOwn(AGENTS, options.agent)) throw new Error(`--agent ${Object.keys(AGENTS).join('|')} is required`); + if (!['project', 'global'].includes(options.scope)) throw new Error('--scope project|global is required'); + if (options.scope === 'project' && !options.project) throw new Error('--project is required for project scope'); + const requestedRoot = options.scope === 'global' ? os.homedir() : path.resolve(options.project); + if (!exists(requestedRoot) || !fs.statSync(requestedRoot).isDirectory()) throw new Error(`Installation root must exist: ${requestedRoot}`); + const root = fs.realpathSync(requestedRoot); + const store = path.join(root, '.organon'); + noSymlinkAncestors(store); + return { ...options, root, store, stateFile: path.join(store, 'installations.json') }; +} +function stateOf(ctx) { + noSymlinkAncestors(ctx.stateFile); + const state = exists(ctx.stateFile) ? json(ctx.stateFile) : { schema: 1, products: {} }; + if (state.schema !== 1 || !state.products || typeof state.products !== 'object') throw new Error('Invalid installation state'); + for (const [product, entry] of Object.entries(state.products)) { + if (!Object.hasOwn(PRODUCTS, product)) throw new Error('Unknown product in installation state'); + for (const candidate of [entry, entry.previous].filter(Boolean)) { + within(path.join(ctx.store, 'packages', product), candidate.directory); + noSymlinkAncestors(candidate.directory); + if (!/^[a-f0-9]{64}$/.test(candidate.manifestHash)) throw new Error('Invalid installed manifest identity'); + } + for (const [agent, discovery] of Object.entries(entry.discovery ?? {})) { + if (!Object.hasOwn(AGENTS, agent)) throw new Error('Unknown agent in installation state'); + for (const item of discovery) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.skill) || item.file !== path.join(discoveryRoot(agent, ctx.scope, ctx.root), item.skill, 'SKILL.md')) throw new Error('Invalid installed discovery path'); + } + } + } + return state; +} +function assertCurrent(entry) { + const manifest = verifyPackage(entry.directory); + if (manifest.manifestHash !== entry.manifestHash) throw new Error('Installed manifest was modified'); + for (const discovery of Object.values(entry.discovery)) { + for (const item of discovery) if (!matches(item.file, item.sha256)) throw new Error(`Installed skill was modified or removed: ${item.file}`); + } +} +function renderSkill(source, destination, directory) { + const payload = path.join(directory, 'payload'); + const text = fs.readFileSync(source, 'utf8'); + const canonical = path.dirname(source); + let rendered = text.replace(/\]\(([^\s)]+)\)/g, (whole, href) => { + if (/^(?:[a-z]+:|#|\/)/i.test(href)) return whole; + const [relative, fragment] = href.split('#'); + const target = path.resolve(canonical, decodeURIComponent(relative)); + return `](${path.relative(path.dirname(destination), target).split(path.sep).map(part => encodeURIComponent(part)).join('/')}${fragment ? `#${fragment}` : ''})`; + }); + // Keep the caller's cwd unchanged. Script roots are explicit, including the + // management skill's formerly repository-relative command examples. + rendered = rendered.replace(/node (?:\/)?scripts\/([\w/-]+\.js)/g, (_, script) => `node "${path.join(payload, 'scripts', script)}"`); + rendered = rendered.replaceAll('', payload); + const end = rendered.indexOf('\n---', 4); + const note = `\n\nInstalled runtime directory: \`${payload}\`. Canonical skill directory: \`${canonical}\`. Resolve any remaining repository-relative command or resource from those directories; preserve the caller's workspace for philosophical selection. This installation contains non-Lean methods only.\n`; + return rendered.slice(0, end + 4) + note + rendered.slice(end + 4); +} +function discoveryFor(ctx, directory, manifest, agents) { + const discovery = {}; + const operations = []; + for (const agent of agents) { + discovery[agent] = []; + for (const skill of manifest.skills) { + const file = path.join(discoveryRoot(agent, ctx.scope, ctx.root), skill.name, 'SKILL.md'); + const content = renderSkill(path.join(directory, skill.path), file, directory); + discovery[agent].push({ file, sha256: sha256(content), sourceSha256: manifest.files.find(entry => entry.path === skill.path).sha256, skill: skill.name }); + operations.push({ file, content }); + } + } + return { discovery, operations }; +} +function collisionCheck(ctx, operations, existing) { + const owned = new Map(Object.values(existing?.discovery ?? {}).flat().map(entry => [entry.file, entry.sha256])); + for (const { file } of operations) { + noSymlinkAncestors(file); + if (exists(file) && (!owned.has(file) || !matches(file, owned.get(file)))) throw new Error(`Discovery path conflict: ${file}`); + } +} +function aliases(ctx, entry) { + const warnings = []; + for (const [agent, discovered] of Object.entries(entry.discovery)) { + for (const skill of discovered) { + for (const alias of AGENTS[agent].aliases) { + const file = path.join(ctx.root, alias, skill.skill, 'SKILL.md'); + if (file !== skill.file && exists(file)) { + const ownedAlias = Object.values(entry.discovery).flat().find(item => item.file === file); + warnings.push({ type: 'alias-discovery', agent, skill: skill.skill, file, sameManagedPayload: Boolean(ownedAlias && matches(file, ownedAlias.sha256)), resolution: 'Inspect both discovery paths. Retain the desired integration and explicitly uninstall an unwanted managed integration; agent precedence is not assumed.' }); + } + } + const otherRoot = ctx.scope === 'project' ? os.homedir() : ctx.project && path.resolve(ctx.project); + const otherScope = ctx.scope === 'project' ? 'global' : 'project'; + if (otherRoot) { + const file = path.join(discoveryRoot(agent, otherScope, otherRoot), skill.skill, 'SKILL.md'); + if (file !== skill.file && exists(file)) warnings.push({ type: 'cross-scope-discovery', agent, skill: skill.skill, file, resolution: 'Compare the project and global installations. Explicitly uninstall the unwanted scope; no scope is silently preferred.' }); + } + } + } + return warnings; +} + +export async function install(options, { update = false, bundled } = {}) { + const ctx = context(options); + if (ctx.product === 'advised' && ctx.domain !== 'SoftwareEngineering') throw new Error('AdvisedOrganons requires explicit --domain SoftwareEngineering'); + const source = await openPackage({ ...ctx, bundled }); + try { + return locked(ctx.store, () => { + const recovered = recover(ctx.store, ctx.root); + const state = stateOf(ctx); + const existing = state.products[ctx.product]; + if (existing) assertCurrent(existing); + if (update && !existing) throw new Error('Product is not installed; use install first'); + if (!update && existing && existing.manifestHash !== source.manifest.manifestHash) throw new Error('Different content is already installed; use update'); + if (existing?.manifestHash === source.manifest.manifestHash && existing.discovery[ctx.agent]) return { action: 'unchanged', product: ctx.product, version: existing.version, directory: existing.directory, recovered, warnings: aliases(ctx, existing) }; + const directory = path.join(ctx.store, 'packages', ctx.product, `${source.manifest.version}-${source.manifest.manifestHash.slice(0, 16)}`); + noSymlinkAncestors(directory); + if (!exists(directory)) { + const staging = `${directory}.staging-${process.pid}`; + fs.mkdirSync(path.dirname(staging), { recursive: true }); + fs.cpSync(source.directory, staging, { recursive: true, dereference: false, errorOnExist: true, force: false }); + verifyPackage(staging); fs.renameSync(staging, directory); + } + const manifest = verifyPackage(directory); + const agents = [...new Set([...Object.keys(existing?.discovery ?? {}), ctx.agent])].sort(); + const { discovery, operations } = discoveryFor(ctx, directory, manifest, agents); + collisionCheck(ctx, operations, existing); + const nextFiles = new Set(operations.map(entry => entry.file)); + for (const item of Object.values(existing?.discovery ?? {}).flat()) if (!nextFiles.has(item.file)) operations.push({ file: item.file, content: null }); + const entry = { version: manifest.version, manifestHash: manifest.manifestHash, directory, discovery, domain: ctx.domain ?? existing?.domain ?? null, previous: existing && existing.manifestHash !== manifest.manifestHash ? { version: existing.version, manifestHash: existing.manifestHash, directory: existing.directory } : existing?.previous ?? null }; + state.products[ctx.product] = entry; + operations.push({ file: ctx.stateFile, content: serialize(state) }); + transact(ctx.store, ctx.root, operations); + const retained = []; + const obsolete = existing?.previous; + if (obsolete && obsolete.directory !== directory && obsolete.directory !== entry.previous?.directory) { + try { + if (verifyPackage(obsolete.directory).manifestHash !== obsolete.manifestHash) retained.push(obsolete.directory); + else fs.rmSync(obsolete.directory, { recursive: true }); + } catch { retained.push(obsolete.directory); } + } + return { action: update ? 'updated' : 'installed', product: ctx.product, version: manifest.version, directory, discovery, recovered, retained, warnings: aliases(ctx, entry), capabilityValidation: 'Filesystem content verified; authenticated agent invocation, resource permissions and delegation require separate tests.' }; + }); + } finally { source.close(); } +} + +export function check(options) { + const ctx = context(options); + if (exists(path.join(ctx.store, 'transaction.json'))) throw new Error('Interrupted installation; rerun its lifecycle command to recover before checking'); + const entry = stateOf(ctx).products[ctx.product]; + if (!entry?.discovery[ctx.agent]) throw new Error('Product is not installed for this agent and scope'); + assertCurrent(entry); + const command = AGENTS[ctx.agent].command; + const result = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10000, windowsHide: true }); + return { passed: true, validationScope: 'package-integrity-and-discovery-files', capabilityValidationComplete: false, product: ctx.product, version: entry.version, directory: entry.directory, discovery: entry.discovery[ctx.agent], warnings: aliases(ctx, entry), agent: { command, executableAvailable: !result.error && result.status === 0, authentication: 'not-checked', invocation: 'not-checked', resourcePermissions: 'not-checked', delegation: 'not-checked' } }; +} + +export function rollback(options) { + const ctx = context(options); + return locked(ctx.store, () => { + const recovered = recover(ctx.store, ctx.root); + const state = stateOf(ctx); const entry = state.products[ctx.product]; + if (!entry?.previous) throw new Error('No previous complete installation is available'); + assertCurrent(entry); + const previous = verifyPackage(entry.previous.directory); + if (previous.manifestHash !== entry.previous.manifestHash) throw new Error('Previous installation was modified'); + const { discovery, operations } = discoveryFor(ctx, entry.previous.directory, previous, Object.keys(entry.discovery)); + collisionCheck(ctx, operations, entry); + const nextFiles = new Set(operations.map(item => item.file)); + for (const item of Object.values(entry.discovery).flat()) if (!nextFiles.has(item.file)) operations.push({ file: item.file, content: null }); + state.products[ctx.product] = { ...entry.previous, discovery, domain: entry.domain, previous: { version: entry.version, manifestHash: entry.manifestHash, directory: entry.directory } }; + operations.push({ file: ctx.stateFile, content: serialize(state) }); + transact(ctx.store, ctx.root, operations); + return { action: 'rolled-back', product: ctx.product, version: previous.version, recovered }; + }); +} + +export function uninstall(options) { + const ctx = context(options); + return locked(ctx.store, () => { + const recovered = recover(ctx.store, ctx.root); + const state = stateOf(ctx); const entry = state.products[ctx.product]; + if (!entry?.discovery[ctx.agent]) return { action: 'unchanged', recovered }; + const retained = []; + const operations = []; + for (const item of entry.discovery[ctx.agent]) { + if (matches(item.file, item.sha256)) operations.push({ file: item.file, content: null }); + else if (exists(item.file)) retained.push(item.file); + } + delete entry.discovery[ctx.agent]; + const removeProduct = !Object.keys(entry.discovery).length; + if (removeProduct) delete state.products[ctx.product]; + operations.push({ file: ctx.stateFile, content: serialize(state) }); + transact(ctx.store, ctx.root, operations); + if (removeProduct) { + // Retain content whenever a discovery copy was changed, so its references + // do not become dangling. Payload edits also prevent recursive deletion. + for (const candidate of [entry, entry.previous].filter(Boolean)) { + try { + const manifest = verifyPackage(candidate.directory); + if (manifest.manifestHash !== candidate.manifestHash || retained.length) retained.push(candidate.directory); + else fs.rmSync(candidate.directory, { recursive: true }); + } catch { retained.push(candidate.directory); } + } + } + return { action: 'uninstalled', product: ctx.product, agent: ctx.agent, retained, recovered, adoption: 'unchanged' }; + }); +} + +export async function initialize(options) { + const ctx = context(options); + if (!options.project) throw new Error('--project is required for explicit project adoption'); + const project = fs.realpathSync(options.project); + const entry = stateOf(ctx).products[ctx.product]; + if (!entry?.discovery[ctx.agent]) throw new Error('Install this product for the selected agent/scope before init'); + assertCurrent(entry); + if (!['core', 'SoftwareEngineering'].includes(options.philosophy)) throw new Error('--philosophy core|SoftwareEngineering is required; installation is not adoption'); + if (options.philosophy === 'SoftwareEngineering' && (ctx.product !== 'advised' || options.domain !== 'SoftwareEngineering')) throw new Error('Select --product advised --domain SoftwareEngineering to adopt this domain'); + const source = path.join(entry.directory, 'payload', options.philosophy === 'core' ? 'OrganonCore/PHILOSOPHY.md' : 'AdvisedOrganons/SoftwareEngineering/PHILOSOPHY.md'); + const target = path.join(project, 'PHILOSOPHY.md'); + const lock = path.join(project, 'PHILOSOPHY.lock.json'); + const instructions = path.join(project, AGENTS[ctx.agent].instructions); + const adoption = path.join(project, '.organon/adoption.json'); + for (const file of [target, lock, instructions, adoption]) noSymlinkAncestors(file); + if (exists(target) || exists(lock) || exists(adoption)) throw new Error('Existing philosophy/adoption is preserved; use the philosophical review workflow for revision'); + const sourceId = options.philosophy === 'core' ? 'https://github.com/shendeguize/OrganonCore' : 'https://github.com/shendeguize/AdvisedOrganons/SoftwareEngineering'; + const block = '\n\nRead [PHILOSOPHY.md](PHILOSOPHY.md) as this workspace\'s adopted philosophy. Preserve its path and real reference directory through Organon delegation. Installation changes do not authorize philosophical revision.\n\n'; + const current = exists(instructions) ? fs.readFileSync(instructions, 'utf8') : ''; + if (current.includes('/g)].map(m => m[1]); + if (JSON.stringify(ids(en)) !== JSON.stringify(ids(zh))) errors.push(`${relative}: EN/ZH stable IDs differ`); + } + return { status: errors.length ? 'failed' : 'passed', checked, errors }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(() => { + const args = parseArgs(); const result = checkContent(args.repo || '.'); + if (args.manifest) { result.source_digest = readJSON(args.manifest).source_digest; result.gate = 'content'; result.product = args.product; } + if (args.out) writeJSON(args.out, result); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; +}); diff --git a/scripts/release/evidence.mjs b/scripts/release/evidence.mjs new file mode 100644 index 0000000..c79b1f7 --- /dev/null +++ b/scripts/release/evidence.mjs @@ -0,0 +1,137 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { readJSON, confined, sha256, digest } from './lib.mjs'; +import { validateReceipt } from '../agents/review.mjs'; + +export function verifyReference(root, reference) { + if (!reference || typeof reference.file !== 'string' || !/^[a-f0-9]{64}$/.test(reference.sha256 || '')) throw new Error('Invalid evidence reference'); + const file = confined(root, reference.file); + if (!fs.statSync(file).isFile() || sha256(fs.readFileSync(file)) !== reference.sha256) throw new Error('Evidence artifact checksum mismatch: ' + reference.file); + return file; +} +// Only explicit {file, sha256} objects form artifact edges. Textual output is never interpreted as an artifact directive. +export function discoverArtifactReferences(value) { + const references = []; + const visit = current => { + if (!current || typeof current !== 'object') return; + if (!Array.isArray(current) && Object.hasOwn(current, 'file') && Object.hasOwn(current, 'sha256')) { + if (typeof current.file !== 'string' || !/^[a-f0-9]{64}$/.test(current.sha256 || '')) throw new Error('Malformed nested artifact reference'); + references.push({ file: current.file, sha256: current.sha256 }); + } + for (const child of Object.values(current)) visit(child); + }; + visit(value); return references; +} +export function artifactChildren(root, reference) { + const file = verifyReference(root, reference); + return reference.file.endsWith('.json') ? discoverArtifactReferences(readJSON(file)) : []; +} +export function evidenceRoots(manifest) { return discoverArtifactReferences(manifest); } +export function collectEvidenceClosure(manifest, root) { + const pending = evidenceRoots(manifest), visited = new Map(); + while (pending.length) { + const ref = pending.shift(), previous = visited.get(ref.file); + if (previous) { if (previous.sha256 !== ref.sha256) throw new Error('Conflicting identities for evidence file: ' + ref.file); continue; } + if (visited.size >= 10000) throw new Error('Evidence closure exceeds file limit'); + visited.set(ref.file, ref); pending.push(...artifactChildren(root, ref)); + } + return [...visited.values()].sort((a, b) => a.file.localeCompare(b.file)); +} +export async function fetchEvidenceClosure(manifest, root, fetchArtifact) { + const pending = evidenceRoots(manifest), visited = new Map(); + while (pending.length) { + const ref = pending.shift(), previous = visited.get(ref.file); + if (previous) { if (previous.sha256 !== ref.sha256) throw new Error('Conflicting identities for evidence file: ' + ref.file); continue; } + if (visited.size >= 10000) throw new Error('Evidence closure exceeds file limit'); + // Confinement is checked before a caller is allowed to download this path. + confined(root, ref.file); + await fetchArtifact(ref); + visited.set(ref.file, ref); pending.push(...artifactChildren(root, ref)); + } + return [...visited.values()].sort((a, b) => a.file.localeCompare(b.file)); +} +export function validateApprovedAgentReport(root, approved) { + const unreviewedFile = verifyReference(root, approved.unreviewed_report); + const receipt = readJSON(verifyReference(root, approved.review_receipt)); + const expected = { ...validateReceipt(root, unreviewedFile, receipt), unreviewed_report: approved.unreviewed_report, review_receipt: approved.review_receipt }; + if (!isDeepStrictEqual(approved, expected)) throw new Error('Approved agent summary differs from its independently reviewed exact original'); + return expected; +} + +export const INVENTORY_FILE = 'trusted-executions.json'; +const COORDINATOR = 'shendeguize/AgentOrganon'; +const SEAL_WORKFLOW = `${COORDINATOR}/.github/workflows/seal.yml@refs/heads/release/1.0.0`; +export function assertSealRole(manifest, environment = process.env) { + if (environment.GITHUB_ACTIONS !== 'true' || environment.GITHUB_EVENT_NAME !== 'workflow_dispatch' || + environment.GITHUB_REPOSITORY !== COORDINATOR || environment.GITHUB_REF !== 'refs/heads/release/1.0.0' || + environment.GITHUB_WORKFLOW_REF !== SEAL_WORKFLOW || environment.RUNNER_ENVIRONMENT !== 'github-hosted' || + environment.GITHUB_SHA !== manifest.sources['agent-organon'].commit || !/^[1-9]\d*$/.test(environment.GITHUB_RUN_ID || '')) { + throw new Error('Sealing requires the exact protected hosted coordinator workflow'); + } +} +export function readTrustedInventory(manifest, root, { required = false, sealing = false, environment = process.env } = {}) { + if (!manifest.trusted_executions) { + if (required || sealing || manifest.state === 'validated') throw new Error('Missing trusted execution inventory'); + return null; + } + if (manifest.trusted_executions.file !== INVENTORY_FILE) throw new Error('Unexpected trusted inventory path'); + const inventory = readJSON(verifyReference(root, manifest.trusted_executions)); + const collector = inventory.collector; + if (inventory.schema_version !== 1 || inventory.source_digest !== manifest.source_digest || inventory.artifact_digest !== digest(manifest.artifacts) || + collector?.repository !== COORDINATOR || collector.workflow !== SEAL_WORKFLOW || collector.source_commit !== manifest.sources['agent-organon'].commit || + !/^[1-9]\d*$/.test(collector.run_id || '') || !/^[1-9]\d*$/.test(inventory.candidate_run || '') || + !Array.isArray(inventory.runs) || !Array.isArray(inventory.files)) throw new Error('Invalid trusted execution inventory identity'); + if (sealing) { + assertSealRole(manifest, environment); + if (collector.run_id !== environment.GITHUB_RUN_ID || collector.run_attempt !== environment.GITHUB_RUN_ATTEMPT) throw new Error('Inventory belongs to a different seal execution'); + } + const runs = new Map(); + for (const run of inventory.runs) { + if (!/^[1-9]\d*$/.test(run.run_id || '') || !/^[1-9]\d*$/.test(run.run_attempt || '') || runs.has(run.run_id) || run.source_commit !== collector.source_commit || + run.repository !== COORDINATOR || !['candidate.yml', 'agent-e2e.yml'].includes(run.workflow) || run.conclusion !== 'success') throw new Error('Invalid trusted workflow run'); + runs.set(run.run_id, run); + } + if (runs.get(inventory.candidate_run)?.workflow !== 'candidate.yml' || [...runs.values()].filter(run => run.workflow === 'candidate.yml').length !== 1) throw new Error('Missing unique trusted candidate run'); + const entries = new Map(); + for (const item of inventory.files) { + confined(root, item.path); + const run = runs.get(item.run_id); + if (entries.has(item.path) || item.path === INVENTORY_FILE || item.path === 'release-manifest.json' || !/^[a-f0-9]{64}$/.test(item.sha256 || '') || + !run || item.workflow !== run.workflow || item.run_attempt !== run.run_attempt || !/^[1-9]\d*$/.test(item.artifact_id || '') || !/^sha256:[a-f0-9]{64}$/.test(item.artifact_digest || '') || !item.artifact?.endsWith('-' + run.run_attempt) || typeof item.artifact !== 'string' || !item.artifact) throw new Error('Invalid trusted artifact inventory entry'); + entries.set(item.path, item); + } + return { inventory, entries }; +} +export function requireTrustedReference(root, trusted, reference, workflow, runID) { + verifyReference(root, reference); + const entry = trusted.entries.get(reference.file); + if (!entry || entry.sha256 !== reference.sha256 || entry.workflow !== workflow || (runID && entry.run_id !== String(runID))) throw new Error('Evidence is not from the required trusted execution: ' + reference.file); + return entry; +} +export function validateTrustedEvidence(manifest, root, options = {}) { + const trusted = readTrustedInventory(manifest, root, options); + if (!trusted) return null; + const candidate = trusted.inventory.candidate_run; + for (const ref of Object.values(manifest.artifacts || {})) requireTrustedReference(root, trusted, ref, 'candidate.yml', candidate); + const packages = new Map(Object.values(manifest.artifacts || {}).map(ref => [ref.file, ref.sha256])); + for (const ref of manifest.evidence || []) { + const report = readJSON(verifyReference(root, ref)); + if (report.gate !== 'agents') { + if (!['content', 'site', 'package', 'github', 'install'].includes(report.gate)) throw new Error('Unexpected mechanical evidence gate'); + for (const child of collectEvidenceClosure({ evidence: [ref] }, root)) requireTrustedReference(root, trusted, child, 'candidate.yml', candidate); + continue; + } + const original = readJSON(verifyReference(root, report.unreviewed_report)); + if (original.ci?.source_commit !== manifest.sources['agent-organon'].commit || !original.ci?.run_id) throw new Error('Agent execution CI identity mismatch'); + const run = String(original.ci.run_id); + if (trusted.inventory.runs.find(item => item.run_id === run)?.run_attempt !== String(original.ci.run_attempt)) throw new Error('Agent execution attempt differs from trusted artifact origin'); + // Raw output, inputs, indices and every captured attachment must share the original run. + // Only the exact release packages referenced by inputs belong to the candidate run. + for (const child of collectEvidenceClosure({ evidence: [report.unreviewed_report] }, root)) { + const isPackage = packages.get(child.file) === child.sha256; + requireTrustedReference(root, trusted, child, isPackage ? 'candidate.yml' : 'agent-e2e.yml', isPackage ? candidate : run); + } + } + return trusted; +} diff --git a/scripts/release/governance.mjs b/scripts/release/governance.mjs new file mode 100644 index 0000000..12d77e1 --- /dev/null +++ b/scripts/release/governance.mjs @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, gh, digest, readJSON, writeJSON, parseArgs, requireValue, main } from './lib.mjs'; + +export const REQUIRED_CHECK = 'Repository / verify'; +export const branchRules = () => ({ name: 'Organon protected branches', target: 'branch', enforcement: 'active', bypass_actors: [], + conditions: { ref_name: { include: ['refs/heads/main', 'refs/heads/dev*', 'refs/heads/dev*/**/*', 'refs/heads/release*', 'refs/heads/release*/**/*'], exclude: [] } }, + rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }, { type: 'pull_request', parameters: { + required_approving_review_count: 0, dismiss_stale_reviews_on_push: true, require_code_owner_review: false, + require_last_push_approval: false, required_review_thread_resolution: true, + } }, { type: 'required_status_checks', parameters: { + strict_required_status_checks_policy: true, required_status_checks: [{ context: REQUIRED_CHECK, integration_id: 15368 }], + } }], +}); +export const tagRules = () => ({ name: 'Organon immutable version tags', target: 'tag', enforcement: 'active', bypass_actors: [], + conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } }, rules: [{ type: 'deletion' }, { type: 'update' }, { type: 'non_fast_forward' }], +}); +const sameRules = (actual, desired) => digest({ target: actual.target, enforcement: actual.enforcement, bypass_actors: actual.bypass_actors || [], conditions: actual.conditions, rules: actual.rules }) === digest({ target: desired.target, enforcement: desired.enforcement, bypass_actors: desired.bypass_actors, conditions: desired.conditions, rules: desired.rules }); +export function allowedRepository(repository) { + if (!Object.values(PRODUCTS).some(p => `shendeguize/${p.repo}` === repository)) throw new Error('Unexpected repository'); + return repository; +} +export async function inspect(repository) { + allowedRepository(repository); + const listed = gh([`repos/${repository}/rulesets`]); + const actual = listed.map(rule => gh([`repos/${repository}/rulesets/${rule.id}`])); + const expected = [branchRules(), tagRules()]; + const missing = expected.filter(rule => !actual.some(item => item.name === rule.name && sameRules(item, rule))).map(rule => rule.name); + return { status: missing.length ? 'failed' : 'passed', repository, missing, rulesets: actual.map(item => ({ id: item.id, name: item.name, enforcement: item.enforcement })) }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const options = parseArgs(); const mode = options._[0] || 'check'; + const repository = allowedRepository(requireValue(options, 'repository')); + if (mode === 'plan') { + const result = { repository, rulesets: [branchRules(), tagRules()], required_check: REQUIRED_CHECK }; + if (options.out) writeJSON(options.out, result); else console.log(JSON.stringify(result, null, 2)); + } else if (mode === 'apply') { + const ref = requireValue(options, 'validated-ref'); + if (!/^[0-9a-f]{40}$/.test(ref)) throw new Error('A full validated commit is required'); + const checks = gh([`repos/${repository}/commits/${ref}/check-runs`]).check_runs; + if (!checks.some(check => check.name === REQUIRED_CHECK && check.conclusion === 'success' && check.app?.slug === 'github-actions')) throw new Error('Required check has not actually succeeded on the supplied revision'); + const existing = gh([`repos/${repository}/rulesets`]); + for (const rule of [branchRules(), tagRules()]) { + const match = existing.find(item => item.name === rule.name); + gh([`repos/${repository}/rulesets${match ? `/${match.id}` : ''}`, '--method', match ? 'PUT' : 'POST'], rule); + } + const result = await inspect(repository); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; + } else if (mode === 'check') { + const result = await inspect(repository); + if (options.manifest) { const manifest = readJSON(options.manifest); result.gate = 'github'; result.product = Object.keys(PRODUCTS).find(k => `shendeguize/${PRODUCTS[k].repo}` === repository); result.source_digest = manifest.source_digest; } + if (options.out) writeJSON(options.out, result); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; + } else throw new Error(`Unknown governance command: ${mode}`); +}); diff --git a/scripts/release/install-matrix.mjs b/scripts/release/install-matrix.mjs new file mode 100644 index 0000000..80cfe09 --- /dev/null +++ b/scripts/release/install-matrix.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, requireValue, readJSON, writeJSON, digest, sha256, main } from './lib.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +main(() => { + const args = parseArgs(); + const files = fs.readdirSync(path.join(root, 'tests')).filter(name => /^(installer|package).*\.test\.mjs$/.test(name)).sort().map(name => path.join(root, 'tests', name)); + const result = spawnSync(process.execPath, ['--test', ...files], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, + env: { ...process.env, ...(args.manifest ? { ORGANON_TEST_CANDIDATE_MANIFEST: path.resolve(args.manifest) } : {}) } }); + if (args.manifest) { + const manifest = readJSON(args.manifest), out = path.resolve(requireValue(args, 'out')); + if (process.env.GITHUB_ACTIONS !== 'true' || process.env.RUNNER_ENVIRONMENT !== 'github-hosted' || process.env.GITHUB_SHA !== manifest.sources['agent-organon'].commit) throw new Error('Release lifecycle evidence requires the exact source on a hosted runner'); + fs.mkdirSync(path.dirname(out), { recursive: true }); + const raw = `${out}.tap`; fs.writeFileSync(raw, `${result.stdout || ''}\n${result.stderr || ''}`, { flag: 'wx' }); + writeJSON(out, { gate: 'install', status: result.status === 0 ? 'passed' : 'failed', platform: process.platform, lifecycle: result.status === 0 ? 'complete' : 'incomplete', + source_digest: manifest.source_digest, artifact_digest: digest(manifest.artifacts), node: process.version, + ci: { run_id: process.env.GITHUB_RUN_ID, source_commit: process.env.GITHUB_SHA }, + raw_response: { file: path.relative(path.dirname(path.resolve(args.manifest)), raw).split(path.sep).join('/'), sha256: sha256(fs.readFileSync(raw)) } }); + } + process.stdout.write(result.stdout || ''); process.stderr.write(result.stderr || ''); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +}); diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs new file mode 100644 index 0000000..8f16e27 --- /dev/null +++ b/scripts/release/lib.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; + +export const PRODUCTS = Object.freeze({ + core: { repo: 'OrganonCore', directory: 'OrganonCore', package: '@shendeguize/organon-core' }, + advised: { repo: 'AdvisedOrganons', directory: 'AdvisedOrganons', package: '@shendeguize/advised-organons' }, + 'agent-organon': { repo: 'AgentOrganon', directory: '.', package: '@shendeguize/agent-organon' }, +}); +export const AGENTS = ['codex', 'claude', 'cursor', 'copilot', 'gemini', 'opencode']; +export const PLATFORMS = ['linux', 'darwin', 'win32']; +export const sha256 = data => crypto.createHash('sha256').update(data).digest('hex'); +export function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])); + return value; +} +export const digest = value => sha256(JSON.stringify(canonical(value))); +export const readJSON = file => JSON.parse(fs.readFileSync(file, 'utf8')); +export function writeJSON(file, data) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); +} +export function parseArgs(argv = process.argv.slice(2)) { + const options = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith('--')) { options._.push(token); continue; } + const key = token.slice(2); + if (key in options) throw new Error(`Duplicate option: ${token}`); + options[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true; + } + return options; +} +export function requireValue(options, key) { + if (typeof options[key] !== 'string' || !options[key]) throw new Error(`Required: --${key} VALUE`); + return options[key]; +} +export function confined(root, relative) { + if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.includes('\\')) throw new Error('Expected relative artifact path'); + const target = path.resolve(root, relative); + if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw new Error('Artifact escapes its root'); + let cursor = target; + while (cursor !== path.resolve(root)) { + if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Symlink artifact path: ${relative}`); + cursor = path.dirname(cursor); + } + return target; +} +export function git(repo, ...args) { return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } +export function gh(args, body) { + return JSON.parse(execFileSync('gh', ['api', ...args, ...(body ? ['--input', '-'] : [])], { + input: body ? JSON.stringify(body) : undefined, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], + }) || 'null'); +} +export function main(fn) { Promise.resolve().then(fn).catch(error => { console.error(error.message); process.exitCode = 1; }); } diff --git a/scripts/release/manifest.mjs b/scripts/release/manifest.mjs new file mode 100644 index 0000000..8ae0d5a --- /dev/null +++ b/scripts/release/manifest.mjs @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, AGENTS, PLATFORMS, sha256, digest, readJSON, writeJSON, confined, git, parseArgs, requireValue, main } from './lib.mjs'; + +import { checkPackage } from '../package/check.mjs'; +import { collectEvidenceClosure, validateApprovedAgentReport, validateTrustedEvidence } from './evidence.mjs'; + +const HEX = /^[a-f0-9]{64}$/; +const COMMIT = /^[a-f0-9]{40}$/; +export const sourceIdentity = manifest => digest({ version: manifest.version, sources: manifest.sources }); +export function assertManifest(manifest, { publish = false } = {}) { + if (manifest.schema_version !== 1 || !/^1\.0\.0(?:-rc\.[1-9]\d*)?$/.test(manifest.version)) throw new Error('Invalid release manifest/version'); + const rc = manifest.version.includes('-rc.'); + if (manifest.channel !== (rc ? 'rc' : 'stable')) throw new Error('Release channel does not match version'); + if (Object.keys(manifest.sources || {}).sort().join() !== Object.keys(PRODUCTS).sort().join()) throw new Error('All three source identities are required'); + for (const [product, definition] of Object.entries(PRODUCTS)) { + const source = manifest.sources[product]; + if (source.repository !== `shendeguize/${definition.repo}` || !COMMIT.test(source.commit)) throw new Error(`Invalid source identity: ${product}`); + if (publish && source.dirty) throw new Error(`Uncommitted source cannot be published: ${product}`); + } + if (manifest.source_digest !== sourceIdentity(manifest)) throw new Error('Source digest mismatch'); + if (publish && manifest.state !== 'validated') throw new Error('Candidate has not completed all validation gates'); + if (!rc && (!manifest.approved_rc || !/^1\.0\.0-rc\.[1-9]\d*$/.test(manifest.approved_rc.version) || !HEX.test(manifest.approved_rc.manifest_sha256))) throw new Error('Stable publication needs an exact approved RC manifest'); + return manifest; +} +export function assertArtifact(root, artifact) { + if (!artifact || !HEX.test(artifact.sha256)) throw new Error('Missing artifact checksum'); + const file = confined(root, artifact.file); + if (!fs.statSync(file).isFile() || sha256(fs.readFileSync(file)) !== artifact.sha256) throw new Error(`Artifact checksum mismatch: ${artifact.file}`); + return file; +} +export function validateEvidence(manifest, root, options = {}) { + assertManifest(manifest); + validateTrustedEvidence(manifest, root, options); + const reports = (manifest.evidence || []).map(item => { + const report = readJSON(assertArtifact(root, item)); + if (report.status !== 'passed' || report.source_digest !== manifest.source_digest || report.artifact_digest !== digest(manifest.artifacts)) throw new Error(`Unsuccessful or unrelated evidence: ${item.file}`); + if (report.gate === 'agents') validateApprovedAgentReport(root, report); + return report; + }); + for (const gate of ['content', 'site', 'package', 'github']) { + for (const product of Object.keys(PRODUCTS)) { + if (!reports.some(r => r.gate === gate && r.product === product)) throw new Error(`Missing ${gate} evidence: ${product}`); + } + } + for (const platform of PLATFORMS) { + if (!reports.some(r => r.gate === 'install' && r.platform === platform && r.lifecycle === 'complete')) throw new Error(`Missing installer lifecycle: ${platform}`); + for (const agent of AGENTS) { + const report = reports.find(r => r.gate === 'agents' && r.agent === agent && r.platform === platform && r.matrix === 'smoke'); + if (!report || !report.actual_call || !report.independent_reviewer || !report.raw_response || !report.tool_version || !report.model) throw new Error(`Missing actual agent smoke: ${agent}/${platform}`); + assertArtifact(root, report.raw_response); + } + } + for (const agent of AGENTS) { + const report = reports.find(r => r.gate === 'agents' && r.agent === agent && r.platform === 'linux' && r.matrix === 'full'); + if (!report || !report.actual_call || !report.independent_reviewer || !report.raw_response || !report.all_non_lean_skills || !report.independent_assessment) throw new Error(`Missing complete actual methods: ${agent}`); + assertArtifact(root, report.raw_response); + assertArtifact(root, report.independent_assessment); + } + validateReleasePackages(manifest, root); + collectEvidenceClosure(manifest, root); + return reports; +} +export function validateReleasePackages(manifest, root) { + assertManifest(manifest); + for (const [product] of Object.entries(PRODUCTS)) { + const checked = checkPackage(assertArtifact(root, manifest.artifacts?.[product])); + if (checked.product !== product || checked.version !== manifest.version) throw new Error('Package product/version does not match release: ' + product); + for (const [sourceProduct, source] of Object.entries(manifest.sources)) { + if (checked.snapshots[PRODUCTS[sourceProduct].repo] !== source.commit) throw new Error('Package source snapshots differ from release: ' + product + '/' + sourceProduct); + } + } + return true; +} +export function assertDispatch(manifest, environment, product = 'agent-organon') { + assertManifest(manifest, { publish: true }); + if (environment.RELEASE_VERSION && environment.RELEASE_VERSION !== manifest.version) throw new Error('Dispatch version differs from approved manifest'); + if (environment.GITHUB_ACTIONS !== 'true' || environment.GITHUB_EVENT_NAME !== 'workflow_dispatch') throw new Error('Publication runs only through explicitly dispatched GitHub Actions'); + if (!PRODUCTS[product] || environment.GITHUB_REPOSITORY !== `shendeguize/${PRODUCTS[product].repo}`) throw new Error('Wrong publication repository'); + if (environment.RELEASE_MANIFEST_SHA256 !== digest(manifest)) throw new Error('Approved manifest identity mismatch'); + if (environment.GITHUB_SHA !== manifest.sources[product].commit) throw new Error('Workflow commit does not match release source'); + if (environment.GITHUB_REF !== 'refs/heads/release/1.0.0') throw new Error('Publication must run from release/1.0.0'); + if (manifest.channel === 'stable' && environment.APPROVED_RC_MANIFEST_SHA256 !== manifest.approved_rc.manifest_sha256) throw new Error('Stable RC approval identity mismatch'); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(() => { + const options = parseArgs(); + const mode = options._[0] || 'check'; + if (mode === 'prepare') { + const workspace = path.resolve(options.workspace || '.'); + const version = requireValue(options, 'version'); + const sources = Object.fromEntries(Object.entries(PRODUCTS).map(([product, spec]) => { + const repo = path.join(workspace, spec.directory); + const dirty = Boolean(git(repo, 'status', '--porcelain=v1', '--untracked-files=normal')); + if (dirty && !options['allow-dirty']) throw new Error(`Source has uncommitted work: ${product}`); + return [product, { repository: `shendeguize/${spec.repo}`, commit: git(repo, 'rev-parse', 'HEAD'), dirty }]; + })); + const manifest = { schema_version: 1, version, channel: version.includes('-rc.') ? 'rc' : 'stable', state: 'prepared', sources, artifacts: {}, evidence: [] }; + if (options['approved-rc']) manifest.approved_rc = readJSON(options['approved-rc']); + manifest.source_digest = sourceIdentity(manifest); + assertManifest(manifest); + writeJSON(requireValue(options, 'out'), manifest); + console.log(JSON.stringify({ status: 'prepared', source_digest: manifest.source_digest })); + } else if (mode === 'check' || mode === 'seal') { + const file = path.resolve(requireValue(options, 'manifest')); + const manifest = readJSON(file); + validateEvidence(manifest, path.dirname(file), { sealing: mode === 'seal' }); + if (mode === 'seal') { manifest.state = 'validated'; assertManifest(manifest, { publish: true }); writeJSON(requireValue(options, 'out'), manifest); } + console.log(JSON.stringify({ status: 'passed', manifest_sha256: digest(manifest), source_digest: manifest.source_digest })); + } else throw new Error(`Unknown manifest command: ${mode}`); +}); diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs new file mode 100644 index 0000000..fb17023 --- /dev/null +++ b/scripts/release/publish.mjs @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, sha256, digest, readJSON, writeJSON, confined, gh, parseArgs, requireValue, main } from './lib.mjs'; +import { assertManifest, assertArtifact, assertDispatch, validateEvidence } from './manifest.mjs'; +import { collectEvidenceClosure, fetchEvidenceClosure } from './evidence.mjs'; +import { validateStable } from './stable.mjs'; + +export const assetName = item => item.file.endsWith('.tgz') ? path.basename(item.file) : `${item.sha256}-${path.basename(item.file)}`; +const execute = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }); +const integrity = file => `sha512-${crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`; +export function releaseFiles(manifest, root) { + const items = collectEvidenceClosure(manifest, root), names = new Map(); + for (const item of items) { + const name = assetName(item); + if (names.has(name) && names.get(name) !== item.sha256) throw new Error(`Conflicting release asset name: ${name}`); + names.set(name, item.sha256); + } + return [...new Map(items.map(item => [assetName(item), item])).values()]; +} +export async function registryVersion(product, version) { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PRODUCTS[product].package)}/${version}`); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`npm registry lookup failed: HTTP ${response.status}`); + return response.json(); +} +async function publishNpm(manifest, product, file) { + const previous = await registryVersion(product, manifest.version); + const expected = integrity(file); + if (previous) { + if (previous.dist?.integrity !== expected) throw new Error(`Published version has different bytes: ${product}`); + return 'already-matching'; + } + const strategy = process.env.NPM_CHANNEL_STRATEGY; + if (!['direct', 'deferred'].includes(strategy)) throw new Error('Explicit approved NPM_CHANNEL_STRATEGY is required'); + const temporaryTag = strategy === 'direct' ? (manifest.channel === 'rc' ? 'rc' : 'latest') : `candidate-${manifest.version.replace(/\./g, '-')}`; + execute('npm', ['publish', file, '--access', 'public', '--provenance', '--tag', temporaryTag], { stdio: 'inherit' }); + const current = await registryVersion(product, manifest.version); + if (current?.dist?.integrity !== expected) throw new Error(`npm post-publication integrity mismatch: ${product}`); + return 'published'; +} +function getRelease(repository, tag) { + try { return gh([`repos/${repository}/releases/tags/${tag}`]); } + catch (error) { if (String(error.stderr || error.message).includes('404')) return null; throw error; } +} +function assertTag(repository, tag, commit) { + let object = gh([`repos/${repository}/git/ref/tags/${tag}`]).object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth++) object = gh([`repos/${repository}/git/tags/${object.sha}`]).object; + if (object.type !== 'commit' || object.sha !== commit) throw new Error(`Release tag has different source: ${repository}/${tag}`); +} +function ensureGithub(manifest, product, root, { coordinator = false } = {}) { + const repository = manifest.sources[product].repository; + const tag = `v${manifest.version}`; + let release = getRelease(repository, tag); + if (!release) { + release = gh([`repos/${repository}/releases`, '--method', 'POST'], { tag_name: tag, target_commitish: manifest.sources[product].commit, + name: `${PRODUCTS[product].repo} ${manifest.version}`, draft: true, prerelease: manifest.channel === 'rc', make_latest: 'false', + body: `Product ${manifest.version}. Manifest identity: ${digest(manifest)}. Philosophy and product versions are separate. Install and validation details are in the attached manifest.`, }); + } + if (release.draft && release.target_commitish !== manifest.sources[product].commit) throw new Error('Existing draft targets a different source commit'); + if (!release.draft) assertTag(repository, tag, manifest.sources[product].commit); + else { + try { gh([`repos/${repository}/git/ref/tags/${tag}`]); assertTag(repository, tag, manifest.sources[product].commit); } + catch (error) { if (!String(error.stderr || error.message).includes('404')) throw error; } + } + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-publish-')); + try { + const manifestFile = path.join(temporary, 'release-manifest.json'); writeJSON(manifestFile, manifest); + const items = coordinator ? releaseFiles(manifest, root) : [manifest.artifacts[product]]; + const upload = [{ file: manifestFile, name: 'release-manifest.json' }, ...items.map(item => ({ file: assertArtifact(root, item), name: assetName(item) }))]; + for (const item of upload) { + const existing = release.assets.find(asset => asset.name === item.name); + if (existing) { + const bytes = execFileSync('gh', ['api', `repos/${repository}/releases/assets/${existing.id}`, '-H', 'Accept: application/octet-stream'], { maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + if (sha256(bytes) !== sha256(fs.readFileSync(item.file))) throw new Error(`Existing release asset differs: ${item.name}`); + } else { + if (!release.draft) throw new Error(`Published release is incomplete; use a new RC: ${item.name}`); + const copied = path.join(temporary, item.name); if (copied !== item.file) fs.copyFileSync(item.file, copied); + execute('gh', ['release', 'upload', tag, copied, '--repo', repository]); + } + } + if (release.draft) gh([`repos/${repository}/releases/${release.id}`, '--method', 'PATCH'], { draft: false, prerelease: manifest.channel === 'rc', make_latest: 'false' }); + assertTag(repository, tag, manifest.sources[product].commit); + } finally { fs.rmSync(temporary, { recursive: true, force: true }); } +} +export async function verifyPublished(manifest, root, products = Object.keys(PRODUCTS)) { + for (const product of products) { + const current = await registryVersion(product, manifest.version); + if (current?.dist?.integrity !== integrity(assertArtifact(root, manifest.artifacts[product]))) throw new Error(`Not all npm products are published with matching bytes: ${product}`); + const repository = manifest.sources[product].repository; + const release = getRelease(repository, `v${manifest.version}`); + if (!release || release.draft || release.prerelease !== (manifest.channel === 'rc')) throw new Error(`GitHub product not published: ${product}`); + assertTag(repository, `v${manifest.version}`, manifest.sources[product].commit); + const checks = [{ name: 'release-manifest.json', expected: sha256(`${JSON.stringify(manifest, null, 2)}\n`) }, + ...((product === 'agent-organon' ? releaseFiles(manifest, root) : [manifest.artifacts[product]])).map(item => ({ name: assetName(item), expected: item.sha256 }))]; + for (const item of checks) { + const asset = release.assets.find(value => value.name === item.name); + if (!asset) throw new Error(`GitHub artifact missing: ${product}/${item.name}`); + const bytes = execFileSync('gh', ['api', `repos/${repository}/releases/assets/${asset.id}`, '-H', 'Accept: application/octet-stream'], { maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + if (sha256(bytes) !== item.expected) throw new Error(`Downloaded GitHub bytes differ: ${product}/${item.name}`); + } + if (!current.dist?.tarball?.startsWith('https://registry.npmjs.org/')) throw new Error('Unexpected npm tarball origin'); + const download = await fetch(current.dist.tarball); + if (!download.ok) throw new Error(`npm tarball download failed: ${product}`); + if (sha256(Buffer.from(await download.arrayBuffer())) !== manifest.artifacts[product].sha256) throw new Error(`Downloaded npm bytes differ: ${product}`); + } +} +async function fetchAsset(version, name, expected, destination) { + const response = await fetch(`https://github.com/shendeguize/AgentOrganon/releases/download/v${version}/${encodeURIComponent(name)}`); + if (!response.ok) throw new Error(`Validated release bundle unavailable: ${name}, HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 64 * 1024 * 1024 || (expected && sha256(bytes) !== expected)) throw new Error(`Invalid bundle asset: ${name}`); + fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.writeFileSync(destination, bytes); +} +export async function fetchBundle(version, expectedManifest, root) { + if (!/^1\.0\.0(?:-rc\.[1-9]\d*)?$/.test(version) || !/^[a-f0-9]{64}$/.test(expectedManifest)) throw new Error('Invalid bundle identity'); + const file = path.join(root, 'release-manifest.json'); + await fetchAsset(version, 'release-manifest.json', null, file); + const manifest = readJSON(file); assertManifest(manifest, { publish: true }); + if (manifest.version !== version || digest(manifest) !== expectedManifest) throw new Error('Downloaded manifest is not the approved object'); + const fetchItem = item => fetchAsset(version, assetName(item), item.sha256, confined(root, item.file)); + await fetchEvidenceClosure(manifest, root, fetchItem); + validateEvidence(manifest, root); return manifest; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const args = parseArgs(); const operation = args._[0]; + if (operation === 'fetch') { const manifest = await fetchBundle(requireValue(args, 'version'), requireValue(args, 'manifest-sha256'), path.resolve(requireValue(args, 'out'))); console.log(JSON.stringify({ status: 'passed', manifest_sha256: digest(manifest) })); return; } + const file = path.resolve(requireValue(args, 'manifest')); const root = path.dirname(file); const manifest = readJSON(file); + validateEvidence(manifest, root); + if (operation === 'verify') { await verifyPublished(manifest, root); console.log(JSON.stringify({ status: 'passed' })); return; } + const product = requireValue(args, 'product'); assertDispatch(manifest, process.env, product); + if (manifest.channel === 'stable') await validateStable(manifest, root); + if (operation === 'bootstrap-bundle') { + if (product !== 'agent-organon') throw new Error('Only the coordinator publishes the validated bundle'); + ensureGithub(manifest, product, root, { coordinator: true }); + } else if (operation === 'publish') { + if (process.env.NPM_CHANNEL_STRATEGY === 'direct') await verifyPublished(manifest, root, Object.keys(PRODUCTS).slice(0, Object.keys(PRODUCTS).indexOf(product))); + await publishNpm(manifest, product, assertArtifact(root, manifest.artifacts[product])); + ensureGithub(manifest, product, root, { coordinator: product === 'agent-organon' }); + } else if (operation === 'promote') { + await verifyPublished(manifest, root); + if (process.env.NPM_CHANNEL_STRATEGY === 'deferred') { + if (!process.env.NODE_AUTH_TOKEN) throw new Error('Deferred npm channel promotion requires a separately authorized tag-management credential; OIDC does not support dist-tag'); + execute('npm', ['dist-tag', 'add', `${PRODUCTS[product].package}@${manifest.version}`, manifest.channel === 'rc' ? 'rc' : 'latest'], { stdio: 'inherit' }); + } else if (process.env.NPM_CHANNEL_STRATEGY !== 'direct') throw new Error('Explicit approved NPM_CHANNEL_STRATEGY is required'); + const release = getRelease(manifest.sources[product].repository, `v${manifest.version}`); + if (manifest.channel === 'stable') gh([`repos/${manifest.sources[product].repository}/releases/${release.id}`, '--method', 'PATCH'], { make_latest: 'true' }); + } else throw new Error('Expected fetch, bootstrap-bundle, publish, promote or verify'); + console.log(JSON.stringify({ status: 'passed', operation, product, version: manifest.version, manifest_sha256: digest(manifest) })); +}); diff --git a/scripts/release/security.mjs b/scripts/release/security.mjs new file mode 100644 index 0000000..340719c --- /dev/null +++ b/scripts/release/security.mjs @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gh, parseArgs, requireValue, main, writeJSON } from './lib.mjs'; +import { allowedRepository } from './governance.mjs'; + +export const environmentPlan = repository => [ + { name: 'npm-rc', branches: ['release/1.0.0'], review: false }, + { name: 'npm-stable', branches: ['release/1.0.0'], review: true }, + { name: 'github-pages', branches: ['main', 'release/1.0.0'], review: false }, + ...(repository.endsWith('/AgentOrganon') ? [ + { name: 'agent-validation', branches: ['main', 'release/1.0.0'], review: true }, + { name: 'release-validation', branches: ['release/1.0.0'], review: true }, + ] : []), +]; +export function inspectSecurity(repository) { + allowedRepository(repository); + const actions = gh([`repos/${repository}/actions/permissions`]); + const workflow = gh([`repos/${repository}/actions/permissions/workflow`]); + const missing = []; + if (!actions.enabled || actions.sha_pinning_required !== true) missing.push('full Action revision pinning'); + if (workflow.default_workflow_permissions !== 'read' || workflow.can_approve_pull_request_reviews !== false) missing.push('read-only default token without PR approval'); + const environments = []; + for (const wanted of environmentPlan(repository)) { + let actual; + try { actual = gh([`repos/${repository}/environments/${wanted.name}`]); } + catch (error) { if (!String(error.stderr || error.message).includes('404')) throw error; missing.push(`environment ${wanted.name}`); continue; } + if (actual.deployment_branch_policy?.protected_branches !== false || actual.deployment_branch_policy?.custom_branch_policies !== true) missing.push(`${wanted.name}: exact deployment branches`); + const branches = gh([`repos/${repository}/environments/${wanted.name}/deployment-branch-policies`]).branch_policies; + if (branches.some(item => item.type !== 'branch') || branches.map(item => item.name).sort().join() !== [...wanted.branches].sort().join()) missing.push(`${wanted.name}: deployment branch list`); + const reviewer = actual.protection_rules.find(item => item.type === 'required_reviewers'); + if (wanted.review && (!reviewer || reviewer.prevent_self_review !== false || reviewer.reviewers.length !== 1 || reviewer.reviewers[0].reviewer.login !== 'shendeguize' || actual.can_admins_bypass !== false)) missing.push(`${wanted.name}: owner approval without administrator bypass`); + environments.push({ name: wanted.name, can_admins_bypass: actual.can_admins_bypass, branches: branches.map(item => item.name), owner_approval: wanted.review }); + } + return { status: missing.length ? 'failed' : 'passed', repository, missing, actions, workflow, environments }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(() => { + const args = parseArgs(), repository = allowedRepository(requireValue(args, 'repository')); + const operation = args._[0] || 'check'; + if (operation === 'plan') { console.log(JSON.stringify({ repository, environments: environmentPlan(repository), actions: { enabled: true, sha_pinning_required: true }, workflow: { default_workflow_permissions: 'read', can_approve_pull_request_reviews: false } }, null, 2)); return; } + if (operation === 'apply') { + const current = gh([`repos/${repository}/actions/permissions`]); + gh([`repos/${repository}/actions/permissions`, '--method', 'PUT'], { enabled: true, allowed_actions: current.allowed_actions, sha_pinning_required: true }); + gh([`repos/${repository}/actions/permissions/workflow`, '--method', 'PUT'], { default_workflow_permissions: 'read', can_approve_pull_request_reviews: false }); + const owner = gh(['users/shendeguize']); + for (const environment of environmentPlan(repository)) { + gh([`repos/${repository}/environments/${environment.name}`, '--method', 'PUT'], { wait_timer: 0, prevent_self_review: false, + reviewers: environment.review ? [{ type: 'User', id: owner.id }] : [], deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } }); + const existing = gh([`repos/${repository}/environments/${environment.name}/deployment-branch-policies`]).branch_policies; + for (const item of existing) if (item.type !== 'branch' || !environment.branches.includes(item.name)) gh([`repos/${repository}/environments/${environment.name}/deployment-branch-policies/${item.id}`, '--method', 'DELETE']); + for (const name of environment.branches) if (!existing.some(item => item.type === 'branch' && item.name === name)) gh([`repos/${repository}/environments/${environment.name}/deployment-branch-policies`, '--method', 'POST'], { name, type: 'branch' }); + } + // The documented REST update does not expose administrator bypass. Inspect it + // and require the actual GitHub UI setting rather than inventing an API field. + } else if (operation !== 'check') throw new Error('Expected plan, apply or check'); + const result = inspectSecurity(repository); + if (args.out) writeJSON(args.out, result); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; +}); diff --git a/scripts/release/site-data.mjs b/scripts/release/site-data.mjs new file mode 100644 index 0000000..2f202bc --- /dev/null +++ b/scripts/release/site-data.mjs @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, digest, sha256, readJSON, writeJSON, parseArgs, requireValue, main } from './lib.mjs'; +import { assertManifest } from './manifest.mjs'; +import { validateHistory, observe, renderSVG } from './stars.mjs'; +import { fetchBundle, verifyPublished } from './publish.mjs'; + +const MUTABLE = new Set(['assets/stars.json', 'assets/stars.svg']); +export function productFor(repository) { + const product = Object.keys(PRODUCTS).find(key => repository === `shendeguize/${PRODUCTS[key].repo}`); + if (!product) throw new Error('Unexpected site repository'); + return product; +} +export function assertSiteRunner(env, operation) { + productFor(env.GITHUB_REPOSITORY); + if (!['stars', 'pages'].includes(operation) || env.GITHUB_ACTIONS !== 'true' || env.RUNNER_ENVIRONMENT !== 'github-hosted' || !env.RUNNER_TEMP || !env.GITHUB_TOKEN || !['refs/heads/main', 'refs/heads/release/1.0.0'].includes(env.GITHUB_REF) || !(operation === 'stars' ? ['schedule', 'workflow_dispatch'] : ['workflow_dispatch']).includes(env.GITHUB_EVENT_NAME)) throw new Error('Site writes require a reviewed GitHub-hosted workflow'); +} +export function inventory(root) { + if (fs.lstatSync(root).isSymbolicLink() || !fs.statSync(root).isDirectory()) throw new Error('Invalid public site directory'); + const files = {}; + function visit(directory, prefix = '') { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) { + const relative = `${prefix}${entry.name}`; + if (entry.isSymbolicLink()) throw new Error('Site tree contains a symlink'); + if (entry.isDirectory()) visit(path.join(directory, entry.name), `${relative}/`); + else if (entry.isFile()) { if (!MUTABLE.has(relative)) files[relative] = sha256(fs.readFileSync(path.join(directory, entry.name))); } + else throw new Error('Site tree contains a special file'); + } + } + visit(root); + if (!files['index.html'] || !files['zh/index.html']) throw new Error('Missing bilingual site entrypoints'); + return files; +} +export function validateState(root, repository) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name === '.git' && entry.isDirectory()) continue; + if (!['stars.json', 'stars.svg', 'public', 'release-manifest.json', 'recommended-release.json'].includes(entry.name) || entry.isSymbolicLink() || (entry.name === 'public' ? !entry.isDirectory() : !entry.isFile())) throw new Error('Unexpected or unsafe site-data entry'); + } + if (fs.existsSync(path.join(root, 'stars.json'))) validateHistory(readJSON(path.join(root, 'stars.json')), repository); + const recommendation = path.join(root, 'recommended-release.json'); + if (!fs.existsSync(recommendation)) { + if (fs.existsSync(path.join(root, 'public')) || fs.existsSync(path.join(root, 'release-manifest.json'))) throw new Error('Orphan public site without a release receipt'); + return null; + } + const saved = readJSON(recommendation), manifest = readJSON(path.join(root, 'release-manifest.json')); + assertManifest(manifest, { publish: true }); + const product = productFor(repository); + if (saved.schema_version !== 1 || saved.repository !== repository || saved.version !== manifest.version || saved.manifest_sha256 !== digest(manifest) || saved.source_commit !== manifest.sources[product].commit || saved.core_commit !== manifest.sources.core.commit || saved.verification !== 'all-three-npm-and-github' || !saved.verified_at || Number.isNaN(Date.parse(saved.verified_at)) || digest(saved.site_files) !== digest(inventory(path.join(root, 'public')))) throw new Error('Stored public site identity differs from verified release'); + return saved; +} +export async function sampleStars(root, repository, fetcher = fetch, now = new Date(), token) { + const previous = fs.existsSync(path.join(root, 'stars.json')) ? validateHistory(readJSON(path.join(root, 'stars.json')), repository) : { schema_version: 1, repository, observations: [] }; + const saved = validateState(root, repository); + const response = await fetcher(`https://api.github.com/repos/${repository}`, { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(token ? { Authorization: `Bearer ${token}` } : {}) } }); + if (!response.ok) throw new Error(`Star observation failed: HTTP ${response.status}; state unchanged`); + const history = observe(previous, (await response.json()).stargazers_count, now); + writeJSON(path.join(root, 'stars.json'), history); + fs.writeFileSync(path.join(root, 'stars.svg'), renderSVG(history)); + if (saved) copyStars(root); + return Boolean(saved); +} +function copyStars(root) { + const history = readJSON(path.join(root, 'stars.json')); + validateHistory(history, history.repository); + const assets = path.join(root, 'public/assets'); fs.mkdirSync(assets, { recursive: true }); + writeJSON(path.join(assets, 'stars.json'), history); + fs.writeFileSync(path.join(assets, 'stars.svg'), renderSVG(history)); +} +export function installPublic(root, repository, manifest, built, verifiedAt) { + assertManifest(manifest, { publish: true }); + const previous = validateState(root, repository), product = productFor(repository); + const rank = version => version === '1.0.0' ? Infinity : Number(version.split('-rc.')[1]); + if (previous && (rank(previous.version) > rank(manifest.version) || (previous.version === manifest.version && previous.manifest_sha256 !== digest(manifest)))) throw new Error('Cannot replace a recommendation with older or different same-version content'); + const files = inventory(built); + if (!verifiedAt || Number.isNaN(Date.parse(verifiedAt))) throw new Error('Missing completed publication verification time'); + fs.rmSync(path.join(root, 'public'), { recursive: true, force: true }); + fs.cpSync(built, path.join(root, 'public'), { recursive: true }); + writeJSON(path.join(root, 'release-manifest.json'), manifest); + writeJSON(path.join(root, 'recommended-release.json'), { schema_version: 1, repository, version: manifest.version, manifest_sha256: digest(manifest), source_commit: manifest.sources[product].commit, core_commit: manifest.sources.core.commit, verification: 'all-three-npm-and-github', verified_at: verifiedAt, site_files: files }); + if (fs.existsSync(path.join(root, 'stars.json'))) copyStars(root); + validateState(root, repository); +} +export function buildIdentity(repo, core, manifest, product) { + const site = readJSON(path.join(repo, 'site/site.json')); + const theme = readJSON(path.join(core, 'tools/site/package.json')); + if (site.repository !== PRODUCTS[product].repo || site.product !== product || site.version !== manifest.version || site.themeVersion !== theme.version || theme.version !== manifest.version) throw new Error('Site/product/theme version differs from fixed release'); +} +function cleanEnv(scratch) { + const env = Object.fromEntries(['PATH', 'SystemRoot', 'WINDIR', 'LANG', 'LC_ALL'].filter(key => process.env[key]).map(key => [key, process.env[key]])); + const home = path.join(scratch, 'home'), temp = path.join(scratch, 'tmp'); fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(temp, { recursive: true }); + return { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: path.join(home, '.config'), TMPDIR: temp, TMP: temp, TEMP: temp, npm_config_cache: path.join(scratch, 'npm-cache'), CI: 'true' }; +} +const execute = (command, args, options = {}) => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024, ...options }).trim(); +export function checkoutState(root, repository, env, executor = execute) { + fs.mkdirSync(root, { recursive: true }); + const git = (...args) => executor('git', ['-C', root, ...args], { env }); + git('init'); git('remote', 'add', 'origin', `https://github.com/${repository}.git`); + let exists = true; + try { git('ls-remote', '--exit-code', '--heads', 'origin', 'refs/heads/site-data'); } + catch (error) { if (error.status !== 2) throw error; exists = false; } + if (exists) { git('fetch', '--depth=1', 'origin', 'refs/heads/site-data'); git('checkout', '-B', 'site-data', 'FETCH_HEAD'); } + else git('checkout', '--orphan', 'site-data'); + return git; +} +function checkoutSource(scratch, source, env) { + const directory = path.join(scratch, source.repository.split('/')[1]); + fs.mkdirSync(directory, { recursive: true }); + const git = (...args) => execute('git', ['-C', directory, ...args], { env }); + git('init'); git('remote', 'add', 'origin', `https://github.com/${source.repository}.git`); git('fetch', '--depth=1', 'origin', source.commit); git('checkout', '--detach', 'FETCH_HEAD'); + if (git('rev-parse', 'HEAD') !== source.commit) throw new Error('Source checkout identity mismatch'); + return directory; +} +export async function verifyForSite(manifest, bundle, verifier = verifyPublished) { + await verifier(manifest, bundle); + return new Date().toISOString(); +} +async function buildReleased(scratch, repository, version, hash, env) { + const bundle = path.join(scratch, 'bundle'); + const manifest = await fetchBundle(version, hash, bundle); + const verifiedAt = await verifyForSite(manifest, bundle), product = productFor(repository); + const core = checkoutSource(scratch, manifest.sources.core, env); + const repo = product === 'core' ? core : checkoutSource(scratch, manifest.sources[product], env); + if (product === 'agent-organon') { + for (const key of ['core', 'advised']) { + const relative = PRODUCTS[key].directory; + const link = execute('git', ['-C', repo, 'ls-tree', 'HEAD', relative], { env }); + if (!link.startsWith(`160000 commit ${manifest.sources[key].commit}\t`)) throw new Error('Released workspace gitlink mismatch'); + } + fs.rmSync(path.join(repo, 'OrganonCore'), { recursive: true, force: true }); fs.cpSync(core, path.join(repo, 'OrganonCore'), { recursive: true }); + const advised = checkoutSource(scratch, manifest.sources.advised, env); + fs.rmSync(path.join(repo, 'AdvisedOrganons'), { recursive: true, force: true }); fs.cpSync(advised, path.join(repo, 'AdvisedOrganons'), { recursive: true }); + } + if (product === 'advised') { + const tooling = readJSON(path.join(repo, 'release/tooling.json')); + if (tooling.core?.repository !== manifest.sources.core.repository || tooling.core?.commit !== manifest.sources.core.commit) throw new Error('Released domain theme pin differs from manifest'); + } + buildIdentity(repo, core, manifest, product); + execute('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: path.join(core, 'tools/site'), env }); + for (const operation of ['build', 'check']) execute(process.execPath, [path.join(core, `tools/site/${operation}.mjs`), '--repo', repo], { env }); + return { manifest, built: path.join(repo, 'dist/site'), verifiedAt }; +} +export async function runSiteData(args, env = process.env) { + const operation = args._[0]; assertSiteRunner(env, operation); + const scratch = fs.mkdtempSync(path.join(env.RUNNER_TEMP, 'organon-site-')); + const localEnv = cleanEnv(scratch), state = path.join(scratch, 'state'); + const git = checkoutState(state, env.GITHUB_REPOSITORY, localEnv); + let deploy; + if (operation === 'stars') deploy = await sampleStars(state, env.GITHUB_REPOSITORY, fetch, new Date(), env.GITHUB_TOKEN); + else { + const release = await buildReleased(scratch, env.GITHUB_REPOSITORY, requireValue(args, 'version'), requireValue(args, 'manifest-sha256'), localEnv); + installPublic(state, env.GITHUB_REPOSITORY, release.manifest, release.built, release.verifiedAt); deploy = true; + } + // Git configuration is process-local; the token never enters a URL, argv or a persisted config. + const pushEnv = { ...localEnv, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${env.GITHUB_TOKEN}`).toString('base64')}` }; + git('config', 'user.name', 'github-actions[bot]'); git('config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'); + git('add', '--all'); + if (git('status', '--porcelain')) { git('commit', '-m', operation === 'stars' ? 'Record observed GitHub stars' : 'Publish verified release site'); execute('git', ['-C', state, 'push', 'origin', 'HEAD:refs/heads/site-data'], { env: pushEnv }); } + if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `deploy=${deploy}\npublic_dir=${path.join(state, 'public')}\n`); + return { status: 'passed', operation, deploy, repository: env.GITHUB_REPOSITORY }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => console.log(JSON.stringify(await runSiteData(parseArgs())))); diff --git a/scripts/release/site.mjs b/scripts/release/site.mjs new file mode 100644 index 0000000..0032ba5 --- /dev/null +++ b/scripts/release/site.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const core = process.env.ORGANON_CORE_ROOT || path.join(root, 'OrganonCore'); +const mode = process.argv[2]; +if (!['build', 'check'].includes(mode)) throw new Error('Expected build or check'); +const script = path.join(core, 'tools/site', `${mode}.mjs`); +if (!fs.existsSync(script)) throw new Error('Missing fixed Core site tooling; initialize OrganonCore or set ORGANON_CORE_ROOT'); +const result = spawnSync(process.execPath, [script, '--repo', root, ...process.argv.slice(3)], { stdio: 'inherit' }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/release/stable.mjs b/scripts/release/stable.mjs new file mode 100644 index 0000000..d677c3d --- /dev/null +++ b/scripts/release/stable.mjs @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, digest, parseArgs, requireValue, readJSON, main } from './lib.mjs'; +import { assertManifest } from './manifest.mjs'; + +const SHA = /^[a-f0-9]{40}$/; +const HASH = /^[a-f0-9]{64}$/; +const REPOSITORIES = new Set(Object.values(PRODUCTS).map(product => `shendeguize/${product.repo}`)); +const INTERNAL_PACKAGES = new Set([...Object.values(PRODUCTS).map(product => product.package), '@shendeguize/organon-site-tools']); +const gitBlobHash = bytes => crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); +const utf8 = bytes => { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (text.includes('\0')) throw new Error('Binary content cannot receive a release text transformation'); + return text; +}; +function insist(condition, message) { if (!condition) throw new Error(message); } +function checkedRepository(repository) { insist(REPOSITORIES.has(repository), 'Unexpected source repository'); return repository; } +function checkedSHA(sha) { insist(SHA.test(sha), 'Expected an immutable Git object SHA'); return sha; } +async function responseBytes(response, limit) { + insist(response.ok, `Immutable release source unavailable: HTTP ${response.status}`); + const parts = []; let size = 0; + for await (const part of response.body) { + size += part.length; insist(size <= limit, 'Immutable release source exceeds read limit'); parts.push(part); + } + return Buffer.concat(parts); +} + +/** Read-only transport. The test backend supplies the same object-shaped methods. */ +export function githubBackend({ token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN } = {}) { + const request = async (repository, suffix) => { + const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`https://api.github.com/repos/${checkedRepository(repository)}/${suffix}`, { headers, signal: AbortSignal.timeout(30000) }); + return JSON.parse((await responseBytes(response, 32 * 1024 * 1024)).toString('utf8')); + }; + return { + async rcManifest(version) { + insist(/^1\.0\.0-rc\.[1-9]\d*$/.test(version), 'Expected an exact RC version'); + const response = await fetch(`https://github.com/shendeguize/AgentOrganon/releases/download/v${version}/release-manifest.json`, { signal: AbortSignal.timeout(30000) }); + return JSON.parse((await responseBytes(response, 4 * 1024 * 1024)).toString('utf8')); + }, + commit: (repository, sha) => request(repository, `git/commits/${checkedSHA(sha)}`), + tree: (repository, sha) => request(repository, `git/trees/${checkedSHA(sha)}?recursive=1`), + async blob(repository, sha) { + const response = await request(repository, `git/blobs/${checkedSHA(sha)}`); + insist(response.sha === sha && response.encoding === 'base64', 'GitHub returned an unrelated blob'); + const bytes = Buffer.from(response.content, 'base64'); + insist(response.size === bytes.length, 'GitHub blob size mismatch'); + return bytes; + }, + }; +} + +function treeEntries(tree, sha) { + insist(tree.sha === sha && tree.truncated === false && Array.isArray(tree.tree), 'Incomplete or unrelated Git tree'); + const result = new Map(); + for (const entry of tree.tree) { + insist(typeof entry.path === 'string' && entry.path && !entry.path.includes('\\') && !entry.path.split('/').some(part => !part || part === '.' || part === '..') && SHA.test(entry.sha), 'Invalid Git tree entry'); + insist(!result.has(entry.path), 'Duplicate Git tree entry'); + const types = { '040000': 'tree', '100644': 'blob', '100755': 'blob', '120000': 'blob', '160000': 'commit' }; + insist(types[entry.mode] === entry.type, 'Unsupported Git tree mode/type'); + result.set(entry.path, entry); + } + return result; +} +const protectedPath = file => /(?:^|\/)(?:PHILOSOPHY(?:\.lock)?\.json|PHILOSOPHY\.md|lean(?:\/|\.|$)|rationale(?:\/|\.|$)|philosophy(?:\/|\.|$))/i.test(file); +function replaceVersion(text, version) { + const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.replace(new RegExp(`(? { + if (!blobs.has(entry.sha)) { + const bytes = Buffer.from(await backend.blob(repository, entry.sha)); + insist(gitBlobHash(bytes) === entry.sha, 'Git blob does not match its immutable identity'); + blobs.set(entry.sha, bytes); + } + return blobs.get(entry.sha); + }; + const oldPackage = oldEntries.get('package.json'); const newPackage = newEntries.get('package.json'); + insist(oldPackage?.type === 'blob' && newPackage?.type === 'blob', 'Product package metadata is missing'); + const oldMetadata = JSON.parse(utf8(await read(oldPackage))); const newMetadata = JSON.parse(utf8(await read(newPackage))); + insist(oldMetadata.name === spec.package && newMetadata.name === spec.package && oldMetadata.version === rc.version && newMetadata.version === '1.0.0', `Product metadata does not implement the approved version transition: ${product}`); + for (const [file, oldEntry] of oldEntries) { + const newEntry = newEntries.get(file); + insist(oldEntry.type === newEntry.type && oldEntry.mode === newEntry.mode, `Stable transition changes a file type/mode: ${product}/${file}`); + if (oldEntry.type === 'tree') continue; + if (product === 'agent-organon' && ['OrganonCore', 'AdvisedOrganons'].includes(file)) { + const peer = file === 'OrganonCore' ? 'core' : 'advised'; + insist(oldEntry.type === 'commit' && oldEntry.sha === rc.sources[peer].commit && newEntry.sha === manifest.sources[peer].commit, `Peer gitlink does not match the frozen source identity: ${file}`); + if (oldEntry.sha !== newEntry.sha) changes.push({ product, path: file, before: oldEntry.sha, after: newEntry.sha, reason: 'peer-gitlink' }); + continue; + } + if (oldEntry.sha === newEntry.sha) continue; + insist(oldEntry.type === 'blob' && oldEntry.mode !== '120000', `Unapproved symbolic-link or gitlink change: ${product}/${file}`); + const reason = permittedBlob(file, utf8(await read(oldEntry)), utf8(await read(newEntry)), rc, manifest, product); + changes.push({ product, path: file, before: oldEntry.sha, after: newEntry.sha, reason }); + } + if (product === 'agent-organon') for (const peer of ['OrganonCore', 'AdvisedOrganons']) insist(oldEntries.has(peer), `Required peer gitlink is missing: ${peer}`); + repositories[product] = { repository, rc_commit: oldCommit, stable_commit: newCommit, rc_tree: oldObject.tree.sha, stable_tree: newObject.tree.sha }; + } + return { schema_version: 1, gate: 'stable-transition', status: 'passed', approved_rc_manifest_sha256: approvalDigest, stable_manifest_sha256: digest(manifest), source_digest: manifest.source_digest, repositories, changes, approval_binding: 'separate-configured-RC-digest', scope: 'Immutable source differences only; rebuilt packages, fresh validation gates and user approval remain separate requirements.' }; +} + +if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const args = parseArgs(); const file = path.resolve(requireValue(args, 'manifest')); + console.log(JSON.stringify(await validateStable(readJSON(file), path.dirname(file)), null, 2)); +}); diff --git a/scripts/release/stars.mjs b/scripts/release/stars.mjs new file mode 100644 index 0000000..64ea7d6 --- /dev/null +++ b/scripts/release/stars.mjs @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, readJSON, writeJSON, main, PRODUCTS } from './lib.mjs'; + +export function validateHistory(history, repository) { + if (history.schema_version !== 1 || history.repository !== repository || !Array.isArray(history.observations)) throw new Error('Invalid star history identity'); + let previous = ''; + for (const point of history.observations) { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(point.observed_at) || Number.isNaN(Date.parse(point.observed_at)) || point.observed_at <= previous || !Number.isSafeInteger(point.total) || point.total < 0) throw new Error('Invalid or unordered star observation'); + previous = point.observed_at; + } + return history; +} +export function observe(history, total, now = new Date()) { + validateHistory(history, history.repository); + if (!Number.isSafeInteger(total) || total < 0) throw new Error('Invalid GitHub star count'); + const timestamp = now.toISOString(); + const last = history.observations.at(-1); + if (last && timestamp <= last.observed_at) throw new Error('Observation must advance time'); + // At most one actual observation per UTC date; retries cannot rewrite history. + if (last?.observed_at.slice(0, 10) === timestamp.slice(0, 10)) return history; + return { ...history, observations: [...history.observations, { observed_at: timestamp, total }] }; +} +const escape = text => String(text).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +export function renderSVG(history) { + validateHistory(history, history.repository); + const points = history.observations; + const last = points.at(-1); + const firstTime = points.length ? Date.parse(points[0].observed_at) : 0; + const span = last ? Math.max(86400000, Date.parse(last.observed_at) - firstTime) : 86400000; + const ceiling = Math.max(1, ...points.map(p => p.total)); + const xy = p => [56 + (Date.parse(p.observed_at) - firstTime) / span * 700, 176 - p.total / ceiling * 105]; + const segments = []; + for (let i = 1; i < points.length; i++) { + if (Date.parse(points[i].observed_at.slice(0, 10)) - Date.parse(points[i - 1].observed_at.slice(0, 10)) === 86400000) { + const [a, b] = [xy(points[i - 1]), xy(points[i])]; + segments.push(``); + } + } + return `${escape(history.repository)} — observed GitHub starsDaily observed totals, including decreases. Missing dates are gaps. ${last ? `Last observed ${last.observed_at}: ${last.total}.` : 'No observations yet.'}GitHub stars · ${last ? last.total : 'not yet observed'}${escape(history.repository)}${last ? `Observed since ${points[0].observed_at.slice(0, 10)} · Updated ${last.observed_at}` : 'Collection starts with the first successful observation.'}Daily total / 每日总数 · gaps indicate missing observations / 缺样保留断点${segments.join('')}${points.map(p => { const [x,y]=xy(p); return `${p.observed_at}: ${p.total}`; }).join('')}\n`; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const args = parseArgs(); + const repository = args.repository; + if (!Object.values(PRODUCTS).some(p => `shendeguize/${p.repo}` === repository)) throw new Error('Expected one of the three Organon repositories'); + const file = path.resolve(args.history || 'site/public/assets/stars.json'); + let history = fs.existsSync(file) ? validateHistory(readJSON(file), repository) : { schema_version: 1, repository, observations: [] }; + if (!args.render) { + const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + const response = await fetch(`https://api.github.com/repos/${repository}`, { headers }); + if (!response.ok) throw new Error(`GitHub star observation failed: HTTP ${response.status}; previous history preserved`); + history = observe(history, (await response.json()).stargazers_count); + writeJSON(file, history); + } + const svg = path.resolve(args.svg || path.join(path.dirname(file), 'stars.svg')); + fs.mkdirSync(path.dirname(svg), { recursive: true }); + fs.writeFileSync(svg, renderSVG(history)); + console.log(JSON.stringify({ status: 'passed', observations: history.observations.length, latest: history.observations.at(-1) || null })); +}); diff --git a/scripts/release/tooling.mjs b/scripts/release/tooling.mjs new file mode 100644 index 0000000..58a16e2 --- /dev/null +++ b/scripts/release/tooling.mjs @@ -0,0 +1,18 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { readJSON } from './lib.mjs'; + +export function readTooling(root) { + const value = readJSON(path.join(root, 'release/tooling.json')); + if (value.schema_version !== 1) throw new Error('Unsupported tooling manifest'); + for (const [key, repository] of [['workspace', 'shendeguize/AgentOrganon'], ['core', 'shendeguize/OrganonCore']]) { + if (!value[key] && key === 'core') continue; + if (value[key]?.repository !== repository || !/^[a-f0-9]{40}$/.test(value[key]?.commit)) throw new Error(`Invalid fixed tooling source: ${key}`); + } + return value; +} +if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) { + const value = readTooling(process.argv[2] || '.'); + if (!process.env.GITHUB_OUTPUT) throw new Error('Tooling outputs require GitHub Actions'); + for (const key of ['workspace', 'core']) if (value[key]) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value[key].commit}\n`); +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..043dd46 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,12 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const directory = path.join(root, 'tests'); +const files = fs.existsSync(directory) ? fs.readdirSync(directory).filter(name => /\.test\.(?:mjs|js)$/.test(name)).sort().map(name => path.join(directory, name)) : []; +if (!files.length) throw new Error('No owned tests found'); +const result = spawnSync(process.execPath, ['--test', '--test-concurrency=2', ...process.argv.slice(2), ...files], { stdio: 'inherit', cwd: root }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/site/index.md b/site/index.md new file mode 100644 index 0000000..6888f46 --- /dev/null +++ b/site/index.md @@ -0,0 +1,35 @@ +--- +layout: home +hero: + name: "AgentOrganon" + text: "Ground agent judgments and improvements." + tagline: "Workspace methods and philosophy management · Make commitments explicit. Examine grounds. Revise for reasons." + actions: + - theme: brand + text: "Quick start" + link: /quick-start + - theme: alt + text: "Read the philosophy" + link: /philosophy +features: + - title: "Make the baseline explicit" + details: "Distinguish adopted commitments, a proposed change and the method’s own constraints." + - title: "Examine reasons and limits" + details: "Connect a claim to its assumptions, grounds and scope of application." + - title: "Revise for stated reasons" + details: "Examine reasons for change; retain an explicit human decision on philosophical adoption." +--- + +## Why philosophy as a design foundation + +Agents make judgments and propose changes. A philosophy makes their underlying commitments readable, open to criticism and revisable. Organon supplies philosophical text and review methods that ask for reasons and limits. It does not guarantee correct judgments or treat a count of methods as evidence of capability. + +## Three repositories, distinct responsibilities + +| Repository | Responsibility | +| --- | --- | +| [AgentOrganon](https://shendeguize.github.io/AgentOrganon/) | Workspace skills and management of adopted philosophy copies. | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/) | The core philosophy, review methods and bounded Lean evidence. | +| [AdvisedOrganons](https://shendeguize.github.io/AdvisedOrganons/) | A collection of domain philosophies; select a domain explicitly. | + +Installation packages supply non-Lean capabilities; the website retains the philosophy and Lean reader views. diff --git a/site/lean.md b/site/lean.md new file mode 100644 index 0000000..193323f --- /dev/null +++ b/site/lean.md @@ -0,0 +1,10 @@ +# Lean reader directory + +Begin with claims, assumptions and limits, then inspect proof declarations and paired code with line explanations. These pages belong to each philosophy’s source repository; AgentOrganon does not maintain a separate formalization. + +| Source | Content | +| --- | --- | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/lean) | Core targets, statuses, source tracing and paired line explanations. | +| [SoftwareEngineering](https://shendeguize.github.io/AdvisedOrganons/lean) | Domain philosophy with its retained Core baseline; no automatic adoption of a newer Core. | + +Lean `passed`, target `accepted` and source fidelity are reported separately. Bounded results do not establish universal real-world correctness; defining a duty does not prove its fulfillment. Installation packages exclude Lean capabilities; full projects, evidence and checking instructions remain in the source repositories. diff --git a/site/philosophy.md b/site/philosophy.md new file mode 100644 index 0000000..991972c --- /dev/null +++ b/site/philosophy.md @@ -0,0 +1,9 @@ +# Sources of the philosophy + +AgentOrganon uses the Core philosophy for its own maintenance. Workspace assessment uses your explicitly selected adopted philosophy; the bundled Core cannot silently replace it. + +- [Read the Core philosophy](https://shendeguize.github.io/OrganonCore/philosophy) +- [Select a domain philosophy](https://shendeguize.github.io/AdvisedOrganons/philosophy) +- [Understand baselines, proposals and method constraints](understand.md) + +The root `PHILOSOPHY.md` is a read-only source link; workspace adoption uses a separate regular file. Installation updates do not automatically change an adopted philosophy. diff --git a/site/public/assets/stars.json b/site/public/assets/stars.json new file mode 100644 index 0000000..928d329 --- /dev/null +++ b/site/public/assets/stars.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "repository": "shendeguize/AgentOrganon", + "observations": [ + { + "observed_at": "2026-09-15T04:41:11.016Z", + "total": 0 + } + ] +} diff --git a/site/public/assets/stars.svg b/site/public/assets/stars.svg new file mode 100644 index 0000000..98c16fe --- /dev/null +++ b/site/public/assets/stars.svg @@ -0,0 +1 @@ +shendeguize/AgentOrganon — observed GitHub starsDaily observed totals, including decreases. Missing dates are gaps. Last observed 2026-09-15T04:41:11.016Z: 0.GitHub stars · 0shendeguize/AgentOrganonObserved since 2026-09-15 · Updated 2026-09-15T04:41:11.016ZDaily total / 每日总数 · gaps indicate missing observations / 缺样保留断点2026-09-15T04:41:11.016Z: 0 diff --git a/site/rationale.md b/site/rationale.md new file mode 100644 index 0000000..87591a2 --- /dev/null +++ b/site/rationale.md @@ -0,0 +1,6 @@ +# Sources of the rationale + +Rationale supplies reasons and objections without adding philosophical obligations. Read it in the repository that owns the text. + +- [Core rationale](https://shendeguize.github.io/OrganonCore/rationale) +- [SoftwareEngineering rationale](https://shendeguize.github.io/AdvisedOrganons/rationale) diff --git a/site/site.json b/site/site.json new file mode 100644 index 0000000..c719cde --- /dev/null +++ b/site/site.json @@ -0,0 +1,7 @@ +{ + "repository": "AgentOrganon", + "product": "agent-organon", + "version": "1.0.0-rc.1", + "themeVersion": "1.0.0-rc.1", + "philosophyRoot": null +} diff --git a/site/zh/index.md b/site/zh/index.md new file mode 100644 index 0000000..53c7bd5 --- /dev/null +++ b/site/zh/index.md @@ -0,0 +1,35 @@ +--- +layout: home +hero: + name: "AgentOrganon" + text: "让 agent 的判断与改进有依据。" + tagline: "工作区方法与哲学管理 · 明确承诺,审查根据,有理由地修订。" + actions: + - theme: brand + text: "快速开始" + link: /zh/quick-start + - theme: alt + text: "阅读哲学" + link: /zh/philosophy +features: + - title: "明确判断基准" + details: "区分已采纳的承诺、待审查的提议与方法自身的约束。" + - title: "审查理由与限度" + details: "将主张与前提、根据及适用范围联系起来。" + - title: "有理由地修订" + details: "审查改变的理由;哲学采纳保留明确的人类决定。" +--- + +## 为什么采用哲学设计 + +Agent 会进行判断,也会提出改变。哲学让这些判断所依据的承诺可读、可质疑、可修订。Organon 提供哲学文本与审查方法,要求说明理由和边界;它不保证每次判断正确,也不把方法数量当作能力证明。 + +## 三个仓库,各有职责 + +| 仓库 | 职责 | +| --- | --- | +| [AgentOrganon](https://shendeguize.github.io/AgentOrganon/zh/) | 工作区技能与已采纳哲学副本的管理。 | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/zh/) | 核心哲学、审查方法,以及有边界的 Lean 证据。 | +| [AdvisedOrganons](https://shendeguize.github.io/AdvisedOrganons/zh/) | 领域哲学集合;使用时显式选择领域。 | + +安装包只提供非 Lean 能力;网站保留完整哲学及 Lean 读者视图。 diff --git a/site/zh/lean.md b/site/zh/lean.md new file mode 100644 index 0000000..016e3c0 --- /dev/null +++ b/site/zh/lean.md @@ -0,0 +1,10 @@ +# Lean 读者入口 + +从主张、前提和限制读起,再查看证明声明及逐行代码解释。以下页面来自各哲学所属仓库,AgentOrganon 不维护另一份形式化结果。 + +| 本源 | 内容 | +| --- | --- | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/zh/lean) | 核心哲学的目标、状态、来源追踪与逐行对照。 | +| [SoftwareEngineering](https://shendeguize.github.io/AdvisedOrganons/zh/lean) | 领域哲学及其保留的 Core 基准,不自动采纳新 Core。 | + +Lean `passed`、目标 `accepted` 和来源保真状态分别报告。有限结果不证明现实中的普遍正确性;定义义务不证明义务已履行。安装包不包含 Lean 能力,完整工程、证据和检查方式保留在源仓库。 diff --git a/site/zh/philosophy.md b/site/zh/philosophy.md new file mode 100644 index 0000000..62fb656 --- /dev/null +++ b/site/zh/philosophy.md @@ -0,0 +1,9 @@ +# 哲学的来源 + +AgentOrganon 使用 Core 哲学作为自身维护基准。工作区评估使用你显式选择的已采纳哲学,不能自动用捆绑的 Core 替代。 + +- [阅读 Core 哲学](https://shendeguize.github.io/OrganonCore/zh/philosophy) +- [选择领域哲学](https://shendeguize.github.io/AdvisedOrganons/zh/philosophy) +- [理解基准、提议和方法约束](understand.md) + +根目录 `PHILOSOPHY.md` 是只读源链接;工作区采纳使用独立的普通文件。安装更新不自动改变已采纳哲学。 diff --git a/site/zh/rationale.md b/site/zh/rationale.md new file mode 100644 index 0000000..a11fff8 --- /dev/null +++ b/site/zh/rationale.md @@ -0,0 +1,6 @@ +# 论证的来源 + +Rationale 提供理由和反对意见,不向哲学核心增加义务。请在文本所属仓库中阅读。 + +- [Core 论证](https://shendeguize.github.io/OrganonCore/zh/rationale) +- [SoftwareEngineering 论证](https://shendeguize.github.io/AdvisedOrganons/zh/rationale) diff --git a/skills/organon-absorb/SKILL.md b/skills/organon-absorb/SKILL.md index a0bed17..d106a5f 100644 --- a/skills/organon-absorb/SKILL.md +++ b/skills/organon-absorb/SKILL.md @@ -8,4 +8,4 @@ description: Examine reasons to revise the calling workspace's adopted philosoph 1. Resolve the philosophy using [the resolution contract](../../OrganonCore/skills/references/philosophy-resolution.md). Run `node /scripts/resolve.js --cwd ` with `--philosophy ` when explicitly supplied. Locate `` from this skill's directory; preserve the caller's directory for resolution. 2. If resolution fails, stop. For missing default candidates, suggest `organon-philosophy init`; an invalid explicit path must be corrected, not replaced by a default or Core. 3. Read the selected `core_version` and compare it with `organon.coreVersion` in [package.json](../../package.json). Surface the resolver's source-version warning when outside the range. If metadata cannot be read, report the version as unknown and retain the selected text as the assessment baseline; managed writes still require valid supported metadata. These warnings are not philosophical validity judgments. -4. Delegate the request to [organon-core-absorb](../../OrganonCore/skills/organon-core-absorb/SKILL.md), passing the resolved philosophy path as both the fixed adopted baseline and the intended adoption target, with its real source directory. Keep a proposed replacement and Core method constraints distinct. Apply Core's required independent review and concrete user decision to that target; a read-only Core symlink requires the explicit Core workflow rather than a management write. +4. Delegate the request to [organon-core-absorb](../../OrganonCore/skills/organon-core-absorb/SKILL.md), passing the resolved philosophy path as the fixed adopted baseline, with its real source directory. Identify any intended adoption target from the request and caller context; pass any established target and the allowed actions without substituting the baseline for a different target. Resolve relative target paths from the caller's directory. A request to revise the caller's own adopted text may use the same path for both roles. For an advisory request without an established target, keep the target unspecified and continue the authorized review; resolution alone does not select a write destination. Keep a proposed replacement and Core method constraints distinct. Apply Core's required independent review and concrete user decision before adoption. Reading a Core symlink as the baseline does not select Core as the adoption target; an actual request to revise protected Core text requires its explicit maintenance or absorption workflow, not a management write or symlink bypass. diff --git a/tests/agents-harness.test.mjs b/tests/agents-harness.test.mjs new file mode 100644 index 0000000..33c7b0f --- /dev/null +++ b/tests/agents-harness.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { assertRunner, settings, isolatedEnv, redact, capture, commandArgs, observedModels, outputFile, artifact, caseTasks, SKILLS } from '../scripts/agents/core.mjs'; +import { validateApprovedAgentReport, collectEvidenceClosure, fetchEvidenceClosure } from '../scripts/release/evidence.mjs'; +import { run } from '../scripts/agents/run.mjs'; +import { validateReceipt } from '../scripts/agents/review.mjs'; +import { sourceIdentity } from '../scripts/release/manifest.mjs'; +import { PRODUCTS, digest, sha256 } from '../scripts/release/lib.mjs'; + +const temporary = t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-agent-unit-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; }; +const fakeManifest = () => { + const m = { schema_version: 1, version: '1.0.0-rc.1', channel: 'rc', state: 'prepared', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([key, p]) => [key, { repository: 'shendeguize/' + p.repo, commit: '1'.repeat(40), dirty: false }])), artifacts: {} }; + m.source_digest = sourceIdentity(m); return m; +}; +const fakeCI = m => ({ GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: 'shendeguize/AgentOrganon', RUNNER_TEMP: '/temporary-runner', GITHUB_REF: 'refs/heads/main', RUNNER_OS: 'Linux', GITHUB_SHA: '1'.repeat(40), RELEASE_MANIFEST_SHA256: digest(m) }); + +test('actual runner boundary rejects local, PR, WSL, wrong source and manifest identities', () => { + const m = fakeManifest(), env = fakeCI(m); + assert.doesNotThrow(() => assertRunner(env, m, 'linux')); + for (const change of [{ GITHUB_ACTIONS: undefined }, { RUNNER_ENVIRONMENT: 'self-hosted' }, { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_REF: 'refs/heads/feature' }, { GITHUB_REPOSITORY: 'attacker/repo' }, { RUNNER_OS: 'Windows' }, { WSL_DISTRO_NAME: 'Ubuntu' }, { GITHUB_SHA: '2'.repeat(40) }, { RELEASE_MANIFEST_SHA256: '0'.repeat(64) }]) assert.throws(() => assertRunner({ ...env, ...change }, m, 'linux')); + assert.throws(() => assertRunner({ ...env, RUNNER_OS: 'Windows' }, m, 'linux'), /Native/); +}); + +test('each child gets only its own key and fresh directories, not parent auth or hooks', t => { + const root = temporary(t); + const source = { PATH: process.env.PATH, OPENAI_API_KEY: 'secret-openai', ANTHROPIC_API_KEY: 'secret-anthropic', CURSOR_API_KEY: 'secret-cursor', COPILOT_GITHUB_TOKEN: 'secret-copilot', GEMINI_API_KEY: 'secret-gemini', GITHUB_TOKEN: 'repository-token', GH_TOKEN: 'gh-token', NODE_OPTIONS: '--require=evil.cjs', HOME: '/real-user', CODEX_HOME: '/real-codex', OPENCODE_MODEL: 'openai/explicit-model' }; + for (const [agent, spec] of Object.entries(settings.agents)) { + const home = path.join(root, agent), env = isolatedEnv(agent, home, source, { auth: true }); + assert.equal(env.HOME, home); assert.equal(env.USERPROFILE, home); assert(env.npm_config_cache.startsWith(home)); + assert.equal(env[spec.child_secret], source[spec.secret]); + for (const key of ['GH_TOKEN', 'GITHUB_TOKEN', 'NODE_OPTIONS', ...Object.values(settings.agents).map(x => x.child_secret).filter(x => x !== spec.child_secret)]) assert.equal(env[key], undefined); + assert.equal(isolatedEnv(agent, home, source)[spec.child_secret], undefined); + } + assert.throws(() => isolatedEnv('codex', root, {}, { auth: true }), /authentication/); + assert.throws(() => isolatedEnv('opencode', root, { OPENAI_API_KEY: 'key' }, { auth: true }), /OPENCODE_MODEL/); +}); + +test('fixture child raw capture redacts secrets, preserves failures and times out entire run', async t => { + const home = temporary(t), env = isolatedEnv('codex', home, process.env), secret = 'fixture-secret-12345'; + const good = await capture(process.execPath, ['-e', 'process.stdout.write(process.argv[1]); process.stderr.write("fixture stderr")', secret], { cwd: home, env, secrets: [secret] }); + assert.equal(good.exit_code, 0); assert.equal(good.stdout, '[REDACTED]'); assert.equal(good.stderr, 'fixture stderr'); + assert(!JSON.stringify(good).includes(secret)); + const failed = await capture(process.execPath, ['-e', 'process.stderr.write("permission denied"); process.exit(7)'], { cwd: home, env }); + assert.equal(failed.exit_code, 7); assert.match(failed.stderr, /permission denied/); + const timeout = await capture(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { cwd: home, env, timeout: 50 }); + assert.equal(timeout.timed_out, true); assert.notEqual(timeout.exit_code, 0); + const absent = await capture(path.join(home, 'missing-tool'), [], { cwd: home, env }); + assert.match(absent.error, /ENOENT/); + const overflow = await capture(process.execPath, ['-e', 'process.stdout.write("a".repeat(100000))'], { cwd: home, env, maxBytes: 100 }); + assert.equal(overflow.output_limit, true); +}); + +test('credential literal, encoded, JSON and base64 forms are removed', () => { + const value = 'a"b\nc?d'; + const input = [value, JSON.stringify(value), encodeURIComponent(value), Buffer.from(value).toString('base64')].join(' '); + const output = redact(input, [value]); + for (const form of [value, encodeURIComponent(value), Buffer.from(value).toString('base64')]) assert(!output.includes(form)); +}); + +test('adapter arguments preserve prompt as a single value and defaults remain unobserved', () => { + const prompt = 'arbitrary "$()" ; prompt'; + for (const agent of Object.keys(settings.agents)) { + const args = commandArgs(agent, prompt, 'explicit/model'); + assert.equal(args.filter(x => x === prompt).length, 1); assert(args.includes('explicit/model')); + } + assert.equal(commandArgs('codex', prompt).includes('--model'), false); + assert.deepEqual(observedModels('codex', '{"model":"invented","independent_reviewer":true}'), []); + assert.deepEqual(observedModels('cursor', '{"type":"system","subtype":"init","model":"observed"}'), ['observed']); + assert.deepEqual(observedModels('gemini', '{"type":"init","model":"observed"}'), ['observed']); +}); + +test('artifact paths are confined and evidence cannot be overwritten', t => { + const root = temporary(t), ref = outputFile(root, 'evidence/raw.txt', 'raw fixture'); + assert.equal(fs.readFileSync(artifact(root, ref), 'utf8'), 'raw fixture'); + assert.throws(() => outputFile(root, '../escape', 'x'), /escapes/); + assert.throws(() => outputFile(root, 'evidence/raw.txt', 'replacement'), /EEXIST/); + fs.writeFileSync(path.join(root, ref.file), 'modified'); assert.throws(() => artifact(root, ref), /mismatch/); +}); + +// This fixture exercises receipt validation only; it is never a real agent result or release artifact. +function fixture(t, matrix = 'smoke') { + const root = temporary(t), skills = matrix === 'full' ? SKILLS : ['organon-assess']; + const source = 'a'.repeat(64), packages = 'b'.repeat(64); + const cases = [], raw = [], receipts = []; + for (const id of skills) { + const text = `UNIT FIXTURE ONLY. Subject session-${id}. Model fixture-model-v1. Discovered installed ${id} through native loader. Invoked installed method ${id}. Read the explicitly selected baseline PHILOSOPHY.md. Independent initial input was frozen. Independent initial response supplied reasons. Candidate compared after preserved initial response.`; + const ref = outputFile(root, id + '-raw.txt', text); raw.push({ id, artifact: ref }); + const cite = quote => ({ artifact: ref, quote }); + cases.push({ id, skill: id, process_status: 'completed' }); + receipts.push({ id, verdict: 'accepted', findings: 'Unit fixture checks receipt structure only, not actual capability.', limits: 'This fixture is not production evidence.', discovered_path: '/fixture/skills/' + id + '/SKILL.md', discovery: cite('Discovered installed ' + id + ' through native loader.'), invocation: cite('Invoked installed method ' + id + '.'), payload_access: cite('Read the explicitly selected baseline PHILOSOPHY.md.'), subject_session: { id: 'session-' + id, evidence: cite('Subject session-' + id + '.') }, delegation: { status: 'completed', reviewer_id: 'native-fixture-reviewer', session_id: 'native-review-' + id, initial_before_candidate: true, initial_input: cite('Independent initial input was frozen.'), initial_response: cite('Independent initial response supplied reasons.'), comparison: cite('Candidate compared after preserved initial response.') } }); + } + const rawRef = outputFile(root, 'raw-index.json', { source_digest: source, artifact_digest: packages, cases: raw }), inputs = outputFile(root, 'inputs.json', { source_digest: source, artifact_digest: packages, cases }); + const report = { gate: 'agents', status: 'needs_independent_review', actual_call: true, probe: false, matrix, platform: 'linux', source_digest: source, artifact_digest: packages, run_id: 'fixture-run', tool_version: 'fixture-version', ci: { run_id: 'fixture-ci', source_commit: '1'.repeat(40) }, raw_response: rawRef, inputs, cases }; + const reportRef = outputFile(root, 'report.json', report), reportFile = path.join(root, reportRef.file); + const assessment = outputFile(root, 'assessment.txt', 'UNIT FIXTURE independent assessment. '.repeat(10)); + const reviewerInput = outputFile(root, 'reviewer-input.txt', [reportRef.sha256, rawRef.sha256, inputs.sha256].join('\n')); + const receipt = { schema_version: 1, report_sha256: reportRef.sha256, source_digest: source, artifact_digest: packages, inputs_sha256: inputs.sha256, raw_sha256: rawRef.sha256, reviewer: { kind: 'agent', id: 'independent-fixture-reviewer', session_id: 'external-fixture-session', harness_author: false, case_author: false }, assessment, reviewer_input: reviewerInput, raw_preserved_before_comparison: true, exposure: 'Fixture only; no actual provider session.', limits: 'No real agent validation.', cases: receipts, observed_model: { name: 'fixture-model-v1', evidence: { artifact: raw[0].artifact, quote: 'Model fixture-model-v1.' } } }; + return { root, reportFile, report, receipt }; +} + +test('complete smoke and exact ten-method full receipt validate without making actual calls', t => { + for (const matrix of ['smoke', 'full']) { const f = fixture(t, matrix); const result = validateReceipt(f.root, f.reportFile, f.receipt); assert.equal(result.status, 'passed'); assert.equal(result.all_non_lean_skills.length || 0, matrix === 'full' ? 10 : 0); } + assert.deepEqual(caseTasks('full').map(x => x.skill), SKILLS); assert.equal(caseTasks('smoke').length, 1); assert.equal(caseTasks('full', true)[0].skill, null); +}); + +test('receipt rejects missing review, author self-review, wrong hashes and unsupported observations', t => { + const f = fixture(t); + const mutations = [r => r.report_sha256 = 'c'.repeat(64), r => r.artifact_digest = 'c'.repeat(64), r => r.reviewer.harness_author = true, r => r.reviewer.session_id = 'fixture-run', r => r.reviewer.session_id = r.cases[0].subject_session.id, r => delete r.assessment, r => r.raw_preserved_before_comparison = false, r => r.cases[0].verdict = 'incomplete', r => r.cases[0].delegation.status = 'incomplete', r => r.cases[0].discovery.quote = 'This text is not in actual output', r => delete r.observed_model, r => r.cases.push(r.cases[0]), r => r.inputs_sha256 = '0'.repeat(64)]; + for (const mutate of mutations) { const receipt = structuredClone(f.receipt); mutate(receipt); assert.throws(() => validateReceipt(f.root, f.reportFile, receipt)); } +}); + +test('full receipt cannot drop an entry or downgrade required absorption delegation', t => { + const f = fixture(t, 'full'); + const missing = structuredClone(f.receipt); missing.cases.pop(); assert.throws(() => validateReceipt(f.root, f.reportFile, missing), /coverage/); + const incomplete = structuredClone(f.receipt); incomplete.cases.find(c => c.id === 'organon-absorb').delegation = { status: 'not_required', reason: 'not available' }; + assert.throws(() => validateReceipt(f.root, f.reportFile, incomplete), /Absorption/); +}); + +test('process failure or probe can never be converted into release success', t => { + const f = fixture(t); + for (const change of [{ status: 'failed' }, { actual_call: false }, { probe: true }, { model: null, cases: [{ ...f.report.cases[0], process_status: 'failed' }] }]) { + const report = { ...f.report, ...change }; fs.writeFileSync(f.reportFile, JSON.stringify(report)); + const receipt = { ...f.receipt, report_sha256: sha256(fs.readFileSync(f.reportFile)) }; + assert.throws(() => validateReceipt(f.root, f.reportFile, receipt)); + } +}); + + +test('seal replays exact receipt and recursively retains every independently reviewed artifact', async t => { + const f = fixture(t, 'smoke'); + const original = { file: 'report.json', sha256: sha256(fs.readFileSync(f.reportFile)) }; + const receiptRef = outputFile(f.root, 'receipt.json', f.receipt); + const approved = { ...validateReceipt(f.root, f.reportFile, f.receipt), unreviewed_report: original, review_receipt: receiptRef }; + const approvedRef = outputFile(f.root, 'approved.json', approved); + assert.doesNotThrow(() => validateApprovedAgentReport(f.root, approved)); + for (const change of [{ model: 'invented' }, { independent_reviewer: true }, { platform: 'win32' }, { all_non_lean_skills: true }, { source_digest: 'e'.repeat(64) }]) assert.throws(() => validateApprovedAgentReport(f.root, { ...approved, ...change }), /differs/); + assert.throws(() => validateApprovedAgentReport(f.root, { ...approved, unreviewed_report: undefined }), /reference/); + const manifest = { evidence: [approvedRef], artifacts: {} }; + const closure = collectEvidenceClosure(manifest, f.root); + for (const expected of ['approved.json', 'report.json', 'receipt.json', 'inputs.json', 'raw-index.json', 'organon-assess-raw.txt', 'assessment.txt', 'reviewer-input.txt']) assert(closure.some(ref => ref.file === expected), expected); + const destination = temporary(t), fetched = []; + await fetchEvidenceClosure(manifest, destination, async ref => { fetched.push(ref.file); fs.mkdirSync(path.dirname(path.join(destination, ref.file)), { recursive: true }); fs.copyFileSync(path.join(f.root, ref.file), path.join(destination, ref.file)); }); + assert.equal(fetched.length, closure.length); + assert.deepEqual(collectEvidenceClosure(manifest, destination), closure); + fs.writeFileSync(path.join(destination, 'organon-assess-raw.txt'), 'changed nested evidence'); + assert.throws(() => collectEvidenceClosure(manifest, destination), /checksum/); +}); + + +test('missing credential writes a blocked evidence bundle without spawning a provider', async t => { + const root = temporary(t), manifest = fakeManifest(); + const manifestFile = path.join(root, 'release-manifest.json'); fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + const platformName = { linux: 'Linux', darwin: 'macOS', win32: 'Windows' }[process.platform]; + const env = { ...fakeCI(manifest), RUNNER_TEMP: root, RUNNER_OS: platformName }; + const report = await run({ agent: 'codex', matrix: 'smoke', manifest: manifestFile, 'package-dir': root, out: path.join(root, 'blocked') }, env); + assert.equal(report.status, 'blocked'); assert.equal(report.actual_call, false); assert.match(report.reason, /Missing authentication/); + const raw = JSON.parse(fs.readFileSync(artifact(root, report.raw_response))); + assert.equal(raw.artifact_digest, digest(manifest.artifacts)); assert.deepEqual(raw.cases, []); + assert(fs.existsSync(path.join(root, 'blocked/report.json'))); +}); diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs new file mode 100644 index 0000000..d8799b5 --- /dev/null +++ b/tests/installer.test.mjs @@ -0,0 +1,254 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { buildPackage } from '../scripts/package/build.mjs'; +import { extractTarball } from '../installer/lib/archive.mjs'; +import { sha256, serialize, write } from '../installer/lib/files.mjs'; +import { transact } from '../installer/lib/transaction.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const candidateFile = process.env.ORGANON_TEST_CANDIDATE_MANIFEST; +const candidate = candidateFile ? JSON.parse(fs.readFileSync(candidateFile, 'utf8')) : null; +const firstVersion = candidate?.version || '1.0.0-rc.1'; +const nextVersion = firstVersion.includes('-rc.') ? firstVersion.replace(/rc\.(\d+)$/, (_, n) => `rc.${Number(n) + 1}`) : '1.0.1'; +function sandbox(t) { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'organon-install-test-'))); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const home = path.join(directory, 'home'); const project = path.join(directory, 'project with spaces'); + fs.mkdirSync(home); fs.mkdirSync(project); + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|CONFIG|HOME|NODE_OPTIONS)/i.test(key))); + const env = { ...inherited, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: path.join(home, '.config'), CODEX_HOME: path.join(home, '.codex'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'), GEMINI_CLI_HOME: home, npm_config_cache: path.join(home, '.npm'), npm_config_prefix: path.join(home, '.npm-global'), npm_config_userconfig: path.join(home, '.npmrc'), npm_config_globalconfig: path.join(home, '.npmrc-global') }; + const packages = {}; + function pack(product = 'agent-organon', version = firstVersion) { + if (candidate && version === candidate.version) { + const entry = candidate.artifacts[product]; + const file = path.resolve(path.dirname(candidateFile), entry.file); + assert.equal(sha256(fs.readFileSync(file)), entry.sha256); + return file; + } + const key = `${product}-${version}`; + return packages[key] ??= buildPackage({ product, version, out: path.join(directory, 'artifacts') }).tarball; + } + const runtime = candidate ? path.join(extractTarball(pack(), path.join(directory, 'candidate-runtime')), 'installer/organon.mjs') : path.join(root, 'installer/organon.mjs'); + function cli(command, extra = [], expected = 0, product = 'agent-organon', executable = runtime) { + const result = spawnSync(process.execPath, [executable, command, '--product', product, '--agent', 'codex', '--scope', 'project', '--project', project, ...extra], { cwd: project, env, encoding: 'utf8' }); + assert.equal(result.status, expected, result.stderr || result.stdout); + return JSON.parse(expected ? result.stderr : result.stdout); + } + return { directory, home, project, env, pack, cli, runtime }; +} + +test('three extracted packages install offline from an empty directory and expose distinct skills', t => { + const s = sandbox(t); + const names = new Set(); + for (const product of ['agent-organon', 'core', 'advised']) { + const extracted = extractTarball(s.pack(product), path.join(s.directory, `extract-${product}`)); + const readme = fs.readFileSync(path.join(extracted, 'README.md'), 'utf8'); + const example = readme.match(/`(node installer\/organon\.mjs install [^`]+)`/)[1].split(' ').slice(1).map(value => value === '/path/to/project' ? s.project : value); + const execution = spawnSync(process.execPath, example, { cwd: extracted, env: s.env, encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr); + const result = JSON.parse(execution.stdout); + assert.equal(result.action, 'installed'); + for (const skill of result.discovery.codex) { assert.ok(!names.has(skill.skill)); names.add(skill.skill); } + const checked = s.cli('check', [], 0, product, path.join(extracted, 'installer/organon.mjs')); + assert.equal(checked.agent.authentication, 'not-checked'); + } + assert.ok(!fs.existsSync(path.join(s.project, 'PHILOSOPHY.md'))); + assert.ok(!fs.existsSync(path.join(s.home, '.agents'))); +}); + +test('idempotent install, exact update, rollback and immutable adoption reference closure', t => { + const s = sandbox(t); + const first = s.cli('install', ['--from', s.pack()]); + assert.equal(s.cli('install', ['--from', s.pack()]).action, 'unchanged'); + const preview = s.cli('init', ['--philosophy', 'core']); + assert.equal(preview.action, 'preview'); assert.ok(!fs.existsSync(preview.target)); + write(path.join(s.project, 'AGENTS.md'), 'User instructions.\n'); + s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', s.cli('init', ['--philosophy', 'core']).planSha256]); + const adopted = fs.readFileSync(preview.target, 'utf8'); + assert.match(fs.readFileSync(path.join(s.project, 'AGENTS.md'), 'utf8'), /^User instructions\.\n/); + const rationale = adopted.match(/\[Rationale\]\(([^)]+)\)/)[1]; + assert.ok(fs.existsSync(path.resolve(s.project, decodeURIComponent(rationale)))); + const updated = s.cli('update', ['--from', s.pack('agent-organon', nextVersion)]); + assert.equal(updated.version, nextVersion); + assert.equal(s.cli('rollback').version, firstVersion); + assert.equal(s.cli('check').directory, first.directory); + s.cli('uninstall'); + assert.equal(fs.readFileSync(preview.target, 'utf8'), adopted); + assert.ok(fs.existsSync(path.resolve(s.project, decodeURIComponent(rationale)))); +}); + +test('modified discovery blocks update and survives uninstall with its runtime', t => { + const s = sandbox(t); + const installed = s.cli('install', ['--from', s.pack()]); + const file = installed.discovery.codex[0].file; + fs.appendFileSync(file, '\nUser modification.\n'); + assert.match(s.cli('update', ['--from', s.pack('agent-organon', nextVersion)], 1).error, /modified/); + const result = s.cli('uninstall'); + assert.ok(result.retained.includes(file)); assert.ok(fs.existsSync(installed.directory)); + assert.match(fs.readFileSync(file, 'utf8'), /User modification/); +}); + +test('modified payload blocks check/update and is retained by uninstall', t => { + const s = sandbox(t); + const installed = s.cli('install', ['--from', s.pack()]); + fs.appendFileSync(path.join(installed.directory, 'payload/OrganonCore/PHILOSOPHY.md'), '\nModified.\n'); + assert.match(s.cli('check', [], 1).error, /integrity/); + assert.match(s.cli('update', ['--from', s.pack('agent-organon', nextVersion)], 1).error, /integrity/); + assert.ok(s.cli('uninstall').retained.includes(installed.directory)); +}); + +test('existing project philosophy and destination symlinks are not overwritten', t => { + const s = sandbox(t); + s.cli('install', ['--from', s.pack()]); + write(path.join(s.project, 'PHILOSOPHY.md'), 'Existing adopted text.\n'); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply'], 1).error, /Existing philosophy/); + assert.equal(fs.readFileSync(path.join(s.project, 'PHILOSOPHY.md'), 'utf8'), 'Existing adopted text.\n'); + const other = path.join(s.directory, 'other-project'); fs.mkdirSync(other); + fs.symlinkSync(s.home, path.join(other, '.agents'), process.platform === 'win32' ? 'junction' : 'dir'); + const result = spawnSync(process.execPath, [path.join(root, 'installer/organon.mjs'), 'install', '--product', 'agent-organon', '--agent', 'codex', '--scope', 'project', '--project', other, '--from', s.pack()], { env: s.env, encoding: 'utf8' }); + assert.equal(result.status, 1); assert.match(result.stderr, /Symbolic link/); + assert.ok(!fs.existsSync(path.join(s.home, 'skills'))); +}); + +test('npm offline installation registers the sole working organon executable', t => { + const s = sandbox(t); + const npmCli = process.env.npm_execpath ?? (process.platform === 'win32' + ? path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js') + : fs.realpathSync(execFileSync('which', ['npm'], { encoding: 'utf8' }).trim())); + const result = spawnSync(process.execPath, [npmCli, 'install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--prefix', s.project, s.pack()], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const bin = path.join(s.project, 'node_modules/.bin', process.platform === 'win32' ? 'organon.cmd' : 'organon'); + assert.ok(fs.existsSync(bin)); + const entrypoint = process.platform === 'win32' ? path.join(s.project, 'node_modules/@shendeguize/agent-organon/installer/organon.mjs') : bin; + const help = spawnSync(process.execPath, [entrypoint, '--help'], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(help.status, 0, help.stderr); assert.match(JSON.parse(help.stdout).help, /install\|check\|init/); +}); + +test('recovery refuses independent edits and preserves its journal', t => { + const s = sandbox(t); const installed = s.cli('install', ['--from', s.pack()]); + const file = installed.discovery.codex[0].file; + const before = fs.readFileSync(file).toString('base64'); + const after = Buffer.from('Candidate.').toString('base64'); + const journal = path.join(s.project, '.organon/transaction.json'); + write(journal, serialize({ schema: 1, operations: [{ path: file, before, after }] })); + fs.writeFileSync(file, 'Independent user edit.'); + assert.match(s.cli('install', ['--from', s.pack()], 1).error, /Recovery conflict/); + assert.equal(fs.readFileSync(file, 'utf8'), 'Independent user edit.'); assert.ok(fs.existsSync(journal)); +}); + +test('interrupted multi-file activation recovers before rerunning installation', t => { + const s = sandbox(t); + const installed = s.cli('install', ['--from', s.pack()]); + const file = installed.discovery.codex[0].file; + const before = fs.readFileSync(file).toString('base64'); + const after = Buffer.from('Interrupted candidate.').toString('base64'); + write(path.join(s.project, '.organon/transaction.json'), serialize({ schema: 1, operations: [{ path: file, before, after }] })); + fs.writeFileSync(file, Buffer.from(after, 'base64')); + assert.match(s.cli('check', [], 1).error, /Interrupted/); + const result = s.cli('install', ['--from', s.pack()]); + assert.equal(result.recovered, true); assert.equal(fs.readFileSync(file).toString('base64'), before); + s.cli('check'); +}); + +test('all six adapters install to native paths, retain coinstallation and report alias/scope duplication', t => { + const s = sandbox(t); const archive = s.pack(); + const command = (action, agent, scope = 'project') => { + const args = [path.join(root, 'installer/organon.mjs'), action, '--product', 'agent-organon', '--agent', agent, '--scope', scope, '--project', s.project, ...(action === 'install' ? ['--from', archive] : [])]; + const result = spawnSync(process.execPath, args, { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); + }; + for (const agent of ['codex', 'claude', 'cursor', 'copilot', 'gemini', 'opencode']) { + assert.equal(command('install', agent).action, 'installed'); command('check', agent); + } + assert.ok(command('check', 'copilot').warnings.some(item => item.type === 'alias-discovery')); + command('install', 'codex', 'global'); + assert.ok(command('check', 'codex').warnings.some(item => item.type === 'cross-scope-discovery')); + command('uninstall', 'claude'); command('check', 'codex'); + assert.ok(fs.existsSync(path.join(s.home, '.agents/skills/organon-assess/SKILL.md'))); +}); + +test('explicit domain adoption retains the independent Core checkpoint', t => { + const s = sandbox(t); + s.cli('install', ['--from', s.pack('advised'), '--domain', 'SoftwareEngineering'], 0, 'advised'); + assert.match(s.cli('init', ['--philosophy', 'SoftwareEngineering', '--apply'], 1, 'advised').error, /Select/); + const preview = s.cli('init', ['--philosophy', 'SoftwareEngineering', '--domain', 'SoftwareEngineering'], 0, 'advised'); + s.cli('init', ['--philosophy', 'SoftwareEngineering', '--domain', 'SoftwareEngineering', '--apply', '--plan-sha256', preview.planSha256], 0, 'advised'); + const text = fs.readFileSync(path.join(s.project, 'PHILOSOPHY.md'), 'utf8'); + assert.match(text, /core_version: 0\.1\.2/); +}); + +test('installed management commands operate without source repository access', t => { + const s = sandbox(t); const installed = s.cli('install', ['--from', s.pack()]); + s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', s.cli('init', ['--philosophy', 'core']).planSha256]); + const payload = path.join(installed.directory, 'payload'); + const result = spawnSync(process.execPath, [path.join(payload, 'scripts/resolve.js'), '--cwd', s.project], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).selectedPath, path.join(s.project, 'PHILOSOPHY.md')); + const check = spawnSync(process.execPath, [path.join(payload, 'scripts/check.js'), path.join(s.project, 'PHILOSOPHY.md')], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(check.status, 0, check.stderr); + const management = fs.readFileSync(path.join(s.project, '.agents/skills/organon-philosophy/SKILL.md'), 'utf8'); + assert.ok(!/node scripts\//.test(management)); assert.ok(management.includes(payload)); +}); + +test('adoption apply binds the preceding preview across instruction and package changes', t => { + const s = sandbox(t); s.cli('install', ['--from', s.pack()]); + const first = s.cli('init', ['--philosophy', 'core']); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply'], 1).error, /plan/); + write(path.join(s.project, 'AGENTS.md'), 'Changed after preview.\n'); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', first.planSha256], 1).error, /plan/); + const second = s.cli('init', ['--philosophy', 'core']); + s.cli('update', ['--from', s.pack('agent-organon', nextVersion)]); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', second.planSha256], 1).error, /plan/); + assert.ok(!fs.existsSync(path.join(s.project, 'PHILOSOPHY.md'))); + const final = s.cli('init', ['--philosophy', 'core']); + s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', final.planSha256]); +}); + +test('an edit after journal creation is not overwritten by activation or recovery', t => { + const s = sandbox(t); const store = path.join(s.project, '.organon'); fs.mkdirSync(store); + const file = path.join(s.project, 'owned.txt'); write(file, 'Original.'); + const originalWrite = fs.writeFileSync; + fs.writeFileSync = function (destination, ...args) { + const result = originalWrite.call(fs, destination, ...args); + if (destination === path.join(store, 'transaction.json')) originalWrite.call(fs, file, 'Concurrent user edit.'); + return result; + }; + try { assert.throws(() => transact(store, s.project, [{ file, content: 'Candidate.' }]), /Recovery conflict/); } + finally { fs.writeFileSync = originalWrite; } + assert.equal(fs.readFileSync(file, 'utf8'), 'Concurrent user edit.'); + assert.ok(fs.existsSync(path.join(store, 'transaction.json'))); +}); + +test('Claude and Gemini adoption uses native project instructions for project and global installations', t => { + for (const [agent, name] of [['claude', 'CLAUDE.md'], ['gemini', 'GEMINI.md']]) { + for (const scope of ['project', 'global']) { + const s = sandbox(t); + const run = (command, extra = [], expected = 0) => { + const result = spawnSync(process.execPath, [s.runtime, command, '--product', 'agent-organon', '--agent', agent, '--scope', scope, '--project', s.project, ...extra], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, expected, result.stderr || result.stdout); + return JSON.parse(expected ? result.stderr : result.stdout); + }; + const native = path.join(s.project, name); const other = path.join(s.project, 'AGENTS.md'); + write(native, 'Existing native instructions.\n'); write(other, 'Other instructions.\n'); + run('install', ['--from', s.pack()]); + const preview = run('init', ['--philosophy', 'core']); + assert.equal(preview.instructions, native); + assert.equal(fs.readFileSync(native, 'utf8'), 'Existing native instructions.\n'); + fs.appendFileSync(native, 'Changed since preview.\n'); + assert.match(run('init', ['--philosophy', 'core', '--apply', '--plan-sha256', preview.planSha256], 1).error, /plan/); + const fresh = run('init', ['--philosophy', 'core']); + run('init', ['--philosophy', 'core', '--apply', '--plan-sha256', fresh.planSha256]); + const adopted = fs.readFileSync(native, 'utf8'); + assert.match(adopted, /^Existing native instructions\.\nChanged since preview\.\n/); + assert.match(adopted, /\[PHILOSOPHY\.md\]\(PHILOSOPHY\.md\)/); + assert.equal(fs.readFileSync(other, 'utf8'), 'Other instructions.\n'); + assert.ok(!fs.existsSync(path.join(s.home, name))); + run('uninstall'); + assert.equal(fs.readFileSync(native, 'utf8'), adopted); + } + } +}); diff --git a/tests/management.test.js b/tests/management.test.js index 51bf751..80cb2c1 100644 --- a/tests/management.test.js +++ b/tests/management.test.js @@ -48,9 +48,9 @@ test('source checks cover 22 matching bilingual IDs; root mirror needs no lock', assert.equal(english.sections.length, 22); assert.deepEqual(english.sections.map(s => s.id), chinese.sections.map(s => s.id)); assert.equal(english.sections.map(s => s.raw).join(''), english.body); - // Core 0.1.3 after the approved Assessment clarification; change only with a reviewed text revision. - assert.equal(hash(english.body.replace(/^\n/gm, '')), '41496fa241aa3c3cb6e3abc83992f904f4cbc4555f6fee935b72fc799fe06ee1'); - assert.equal(hash(chinese.body.replace(/^\n/gm, '')), '27d81360a31a44fa207bcee48d08b09da18d2f0d58beb592879b779ac5ad9cf5'); + // Core 0.1.4 after the approved rationale-path maintenance; change only with a reviewed text revision. + assert.equal(hash(english.body.replace(/^\n/gm, '')), '7e77824910fc1b62662dd6163e257b49c1d5120e900c59b458c39e9b5e233c81'); + assert.equal(hash(chinese.body.replace(/^\n/gm, '')), '0e3cb43ba80ed52cd68d39c9a4acbb91dc946321450514c91e212e21f6cac5f7'); assert.equal(check(CORE_FILE, { source: true }).mode, 'source'); assert.equal(check(path.join(ROOT, 'PHILOSOPHY.md'), { source: true }).realPath, fs.realpathSync(CORE_FILE)); assert.equal(fs.existsSync(path.join(ROOT, 'PHILOSOPHY.lock.json')), false); diff --git a/tests/package.test.mjs b/tests/package.test.mjs new file mode 100644 index 0000000..5d10e61 --- /dev/null +++ b/tests/package.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { buildPackage } from '../scripts/package/build.mjs'; +import { checkPackage } from '../scripts/package/check.mjs'; +import { extractTarball } from '../installer/lib/archive.mjs'; +import { verifyPackage, openPackage } from '../installer/lib/package.mjs'; + +test('release payloads are reproducible, symlink-free, complete and omit Lean/docs/tests', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-package-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const installerHashes = []; + for (const product of ['agent-organon', 'core', 'advised']) { + const first = buildPackage({ product, out: directory }); + const second = buildPackage({ product, out: path.join(directory, 'second') }); + assert.equal(first.sha256, second.sha256); + assert.equal(checkPackage(first.tarball).passed, true); + const extracted = extractTarball(first.tarball, path.join(directory, product)); + const manifest = verifyPackage(extracted); + assert.ok(manifest.files.every(entry => !entry.path.includes('/lean/') && !entry.path.includes('/docs/'))); + const mirror = manifest.files.find(entry => entry.path === 'payload/PHILOSOPHY.md'); + assert.ok(mirror.source.transformations.includes('materialize-symbolic-link')); + const metadata = JSON.parse(fs.readFileSync(path.join(extracted, 'package.json'))); + assert.equal(Boolean(metadata.bin), product === 'agent-organon'); + installerHashes.push(manifest.files.filter(entry => entry.path.startsWith('installer/')).map(entry => [entry.path, entry.sha256])); + fs.appendFileSync(path.join(extracted, 'payload/PHILOSOPHY.md'), '\nTampered\n'); + assert.throws(() => verifyPackage(extracted), /integrity/); + } + assert.deepEqual(installerHashes[0], installerHashes[1]); assert.deepEqual(installerHashes[1], installerHashes[2]); +}); + +test('a bundled installer never fetches another product implicitly', async t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-offline-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const archive = buildPackage({ product: 'core', out: directory }).tarball; + const bundled = extractTarball(archive, path.join(directory, 'extracted')); + const originalFetch = globalThis.fetch; + let called = false; + globalThis.fetch = () => { called = true; throw new Error('Unexpected network request'); }; + try { + const local = await openPackage({ product: 'core', bundled }); + assert.equal(local.manifest.product, 'core'); local.close(); + await assert.rejects(openPackage({ product: 'agent-organon', bundled }), /exact --version/); + assert.equal(called, false); + } finally { globalThis.fetch = originalFetch; } +}); + +test('archive extraction rejects traversal, links and checksum tampering before writing', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-archive-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const archive = buildPackage({ product: 'core', out: directory }).tarball; + for (const mutation of ['traversal', 'link', 'checksum']) { + const bytes = gunzipSync(fs.readFileSync(archive)); + if (mutation === 'traversal') { bytes.fill(0, 0, 100); bytes.write('package/../../escape', 0); } + if (mutation === 'link') bytes.write('2', 156); + if (mutation !== 'checksum') { + bytes.fill(32, 148, 156); + const sum = bytes.subarray(0, 512).reduce((total, byte) => total + byte, 0); + bytes.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148); + } else bytes[10] ^= 1; + const bad = path.join(directory, `${mutation}.tgz`); fs.writeFileSync(bad, gzipSync(bytes)); + const destination = path.join(directory, mutation); + assert.throws(() => extractTarball(bad, destination), /Unsafe|Unsupported|checksum/); + assert.ok(!fs.existsSync(destination)); + } +}); diff --git a/tests/release-attempts.test.mjs b/tests/release-attempts.test.mjs new file mode 100644 index 0000000..f9e6555 --- /dev/null +++ b/tests/release-attempts.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertTrustedRun, assertArtifactIdentity, selectAttemptArtifacts, resolveAttempt } from '../scripts/release/actions-artifacts.mjs'; +import { AGENTS } from '../scripts/release/lib.mjs'; +const run = (workflow = 'candidate.yml', attempt = 2) => ({ id: 10, run_attempt: attempt, head_sha: 'a'.repeat(40), repository: { full_name: 'shendeguize/AgentOrganon' }, head_repository: { full_name: 'shendeguize/AgentOrganon' }, path: '.github/workflows/' + workflow, event: 'workflow_dispatch', conclusion: 'success' }); +const artifact = (name, id = 100) => ({ id, name, expired: false, digest: 'sha256:' + 'b'.repeat(64), workflow_run: { id: 10, head_sha: 'a'.repeat(40) } }); +const candidate = attempt => [`candidate-packages-${attempt}`, ...['Linux', 'macOS', 'Windows'].map(os => `install-${os}-${attempt}`)].map((name, i) => artifact(name, attempt * 100 + i)); + +test('complete latest candidate attempt selects exact IDs and never borrows old successful assets', () => { + const current = candidate(2), all = [...candidate(1), ...current]; + assert.deepEqual(selectAttemptArtifacts(run(), all, 'candidate.yml'), current); + for (const missing of current) assert.throws(() => selectAttemptArtifacts(run(), all.filter(a => a !== missing), 'candidate.yml'), /Rerun all jobs/); + assert.throws(() => selectAttemptArtifacts(run(), candidate(1), 'candidate.yml'), /Rerun all jobs/); +}); +test('a full or smoke agent attempt must be complete even when earlier attempts succeeded', () => { + for (const matrix of ['smoke', 'full']) { + const names = (attempt) => AGENTS.flatMap(agent => (matrix === 'full' ? ['Linux'] : ['Linux', 'macOS', 'Windows']).map(os => `agent-${agent}-${os}-${matrix}-${attempt}`)); + const current = names(2).map((name, i) => artifact(name, 100 + i)); + const old = names(1).map((name, i) => artifact(name, 200 + i)); + assert.deepEqual(selectAttemptArtifacts(run('agent-e2e.yml'), [...old, ...current], 'agent-e2e.yml'), current); + assert.throws(() => selectAttemptArtifacts(run('agent-e2e.yml'), [...old, ...current.slice(1)], 'agent-e2e.yml'), /Rerun all jobs/); + } +}); +test('seal artifacts are selected by current attempt and original IDs stay distinct', () => { + const old = artifact('validated-release-1', 10), current = artifact('validated-release-2', 20); + assert.deepEqual(selectAttemptArtifacts(run('seal.yml'), [old, current], 'seal.yml'), [current]); + assert.throws(() => selectAttemptArtifacts(run('seal.yml'), [old], 'seal.yml'), /Rerun all jobs/); +}); +test('expired, duplicate, unrelated and changed artifact identities are rejected', () => { + const r = run(), current = candidate(2); + for (const change of [{ expired: true }, { id: null }, { digest: null }, { workflow_run: { id: 11, head_sha: r.head_sha } }]) assert.throws(() => assertArtifactIdentity({ ...current[0], ...change }, r)); + for (const change of [{ id: 999 }, { name: 'changed' }, { digest: 'sha256:' + 'c'.repeat(64) }]) assert.throws(() => assertArtifactIdentity({ ...current[0], ...change }, r, current[0]), /changed/); + assert.throws(() => selectAttemptArtifacts(r, [...current, current[0]], 'candidate.yml'), /Rerun all jobs/); +}); +test('resolve verifies current run before selecting paginated immutable artifacts', () => { + const calls = [], current = candidate(2), r = run(); + const api = ([route]) => { calls.push(route); if (!route.includes('artifacts?')) return r; return route.endsWith('page=1') ? { total_count: 8, artifacts: candidate(1) } : { total_count: 8, artifacts: current }; }; + assert.deepEqual(resolveAttempt('10', 'candidate.yml', r.head_sha, api).artifacts, current); + assert.equal(calls.length, 3); + for (const change of [{ run_attempt: undefined }, { conclusion: 'failure' }, { path: '.github/workflows/ci.yml' }, { head_sha: 'c'.repeat(40) }]) assert.throws(() => assertTrustedRun({ ...r, ...change }, 'candidate.yml', r.head_sha)); +}); diff --git a/tests/release-evidence.test.mjs b/tests/release-evidence.test.mjs new file mode 100644 index 0000000..9c6b192 --- /dev/null +++ b/tests/release-evidence.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildPackage } from '../scripts/package/build.mjs'; +import { checkPackage } from '../scripts/package/check.mjs'; +import { extractTarball, createTarball } from '../installer/lib/archive.mjs'; +import { validateReleasePackages, sourceIdentity, validateEvidence } from '../scripts/release/manifest.mjs'; +import { PRODUCTS, sha256, digest } from '../scripts/release/lib.mjs'; +import { outputFile } from '../scripts/agents/core.mjs'; +import { collectEvidenceClosure } from '../scripts/release/evidence.mjs'; + +function temp(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-evidence-unit-')); t.after(() => fs.rmSync(root, { force: true, recursive: true })); return root; } + +test('release verifies real archive product, version and all three source snapshots', t => { + const root = temp(t), artifacts = {}, snapshots = {}, fixtureVersion = '1.0.0-rc.1'; + for (const product of Object.keys(PRODUCTS)) { + const built = buildPackage({ product, out: root, version: fixtureVersion }); + artifacts[product] = { file: path.basename(built.tarball), sha256: built.sha256 }; + Object.assign(snapshots, checkPackage(built.tarball).snapshots); + } + const m = { schema_version: 1, version: fixtureVersion, channel: 'rc', state: 'prepared', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([product, p]) => [product, { repository: 'shendeguize/' + p.repo, commit: snapshots[p.repo], dirty: false }])), artifacts, evidence: [] }; + m.source_digest = sourceIdentity(m); + assert.equal(validateReleasePackages(m, root), true); + const swapped = structuredClone(m); swapped.artifacts.core = swapped.artifacts.advised; + assert.throws(() => validateReleasePackages(swapped, root), /product\/version/); + const wrongVersion = structuredClone(m); wrongVersion.version = '1.0.0-rc.2'; wrongVersion.source_digest = sourceIdentity(wrongVersion); + assert.throws(() => validateReleasePackages(wrongVersion, root), /product\/version/); + const wrongSource = structuredClone(m); wrongSource.sources.core.commit = '0'.repeat(40); wrongSource.source_digest = sourceIdentity(wrongSource); + assert.throws(() => validateReleasePackages(wrongSource, root), /snapshots differ/); + const text = outputFile(root, 'not-an-archive.tgz', 'Checksummed text is not a release package.'); + const invalid = structuredClone(m); invalid.artifacts.core = text; + assert.throws(() => validateReleasePackages(invalid, root)); + const unpacked = extractTarball(path.join(root, artifacts.core.file), path.join(root, 'unpacked')); + const manifestFile = path.join(unpacked, 'organon-content.json'), content = JSON.parse(fs.readFileSync(manifestFile)); + content.files.find(file => file.source).source.revision = 'f'.repeat(40); + fs.writeFileSync(manifestFile, JSON.stringify(content)); + assert.throws(() => checkPackage(unpacked), /provenance differs/); +}); + +test('opaque passed summaries cannot replace independently bound actual evidence', t => { + const root = temp(t); + const m = { schema_version: 1, version: '1.0.0-rc.1', channel: 'rc', state: 'prepared', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([product, p]) => [product, { repository: 'shendeguize/' + p.repo, commit: '1'.repeat(40), dirty: false }])), artifacts: {}, evidence: [] }; + m.source_digest = sourceIdentity(m); + const fake = outputFile(root, 'fabricated.json', { gate: 'agents', status: 'passed', source_digest: m.source_digest, artifact_digest: digest(m.artifacts), agent: 'codex', platform: 'linux', matrix: 'smoke', actual_call: true, independent_reviewer: true, model: 'unknown', tool_version: 'unknown' }); + m.evidence = [fake]; assert.throws(() => validateEvidence(m, root), /Invalid evidence reference/); +}); + +test('closure rejects changed nested objects and conflicting same-path identities', t => { + const root = temp(t), raw = outputFile(root, 'raw.txt', 'frozen'); + const report = outputFile(root, 'report.json', { nested: { raw } }); + assert.equal(collectEvidenceClosure({ evidence: [report] }, root).length, 2); + const conflict = outputFile(root, 'conflict.json', { a: raw, b: { file: raw.file, sha256: '0'.repeat(64) } }); + assert.throws(() => collectEvidenceClosure({ evidence: [conflict] }, root), /Conflicting/); + fs.rmSync(path.join(root, 'raw.txt')); + assert.throws(() => collectEvidenceClosure({ evidence: [report] }, root)); +}); diff --git a/tests/release-site-data.test.mjs b/tests/release-site-data.test.mjs new file mode 100644 index 0000000..23712b9 --- /dev/null +++ b/tests/release-site-data.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PRODUCTS, digest, writeJSON } from '../scripts/release/lib.mjs'; +import { sourceIdentity } from '../scripts/release/manifest.mjs'; +import { assertSiteRunner, inventory, validateState, sampleStars, installPublic, buildIdentity, verifyForSite, checkoutState } from '../scripts/release/site-data.mjs'; +const repository = 'shendeguize/AgentOrganon'; +function temporary(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-site-state-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); return root; } +function manifest(version = '1.0.0-rc.1') { + const value = { schema_version: 1, version, channel: 'rc', state: 'validated', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([key, p]) => [key, { repository: `shendeguize/${p.repo}`, commit: 'a'.repeat(40), dirty: false }])), evidence: [], artifacts: {} }; + value.source_digest = sourceIdentity(value); return value; +} +function built(t) { const root = temporary(t); fs.mkdirSync(path.join(root, 'zh')); fs.writeFileSync(path.join(root, 'index.html'), 'English released HTML'); fs.writeFileSync(path.join(root, 'zh/index.html'), '中文发行页面'); return root; } +const api = total => async () => ({ ok: true, json: async () => ({ stargazers_count: total }) }); +test('site writes reject local, foreign, PR and self-hosted contexts', () => { + const env = { GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted', GITHUB_REPOSITORY: repository, GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RUNNER_TEMP: '/tmp', GITHUB_TOKEN: 'fixture' }; + assert.doesNotThrow(() => assertSiteRunner(env, 'pages')); + for (const key of Object.keys(env)) assert.throws(() => assertSiteRunner({ ...env, [key]: '' }, 'pages')); + assert.throws(() => assertSiteRunner({ ...env, GITHUB_EVENT_NAME: 'schedule' }, 'pages')); + assert.doesNotThrow(() => assertSiteRunner({ ...env, GITHUB_EVENT_NAME: 'schedule' }, 'stars')); +}); +test('first actual sample does not create or deploy an unreleased site', async t => { + const root = temporary(t); + assert.equal(await sampleStars(root, repository, api(0), new Date('2026-09-15T00:00:00Z')), false); + assert.equal(validateState(root, repository), null); assert.equal(fs.existsSync(path.join(root, 'public')), false); + const before = fs.readFileSync(path.join(root, 'stars.json')); + await assert.rejects(sampleStars(root, repository, async () => ({ ok: false, status: 403 }), new Date('2026-09-16T00:00:00Z')), /state unchanged/); + assert.deepEqual(fs.readFileSync(path.join(root, 'stars.json')), before); + await assert.rejects(sampleStars(root, repository, api(undefined), new Date('2026-09-16T00:00:00Z')), /Invalid GitHub/); + assert.deepEqual(fs.readFileSync(path.join(root, 'stars.json')), before); +}); +test('daily sampling preserves exact released HTML while retaining decreases and gaps', async t => { + const root = temporary(t), source = built(t), release = manifest(); + installPublic(root, repository, release, source, '2026-09-14T00:00:00Z'); + const before = inventory(path.join(root, 'public')), receipt = fs.readFileSync(path.join(root, 'recommended-release.json')); + for (const [day, count] of [[15, 3], [16, 0], [19, 1]]) assert.equal(await sampleStars(root, repository, api(count), new Date(`2026-09-${day}T00:00:00Z`)), true); + assert.deepEqual(inventory(path.join(root, 'public')), before); assert.deepEqual(fs.readFileSync(path.join(root, 'recommended-release.json')), receipt); + const points = JSON.parse(fs.readFileSync(path.join(root, 'public/assets/stars.json'))).observations; + assert.deepEqual(points.map(p => p.total), [3,0,1]); + assert.equal((fs.readFileSync(path.join(root, 'public/assets/stars.svg'), 'utf8').match(/ observe(history, -1)); + assert.throws(() => observe(history, 0, new Date('2026-09-18T00:00:00Z'))); +}); +test('star gaps follow UTC calendar dates rather than elapsed hours', () => { + for (const [dates, segments] of [ + [['2026-09-14T23:30:00Z', '2026-09-16T00:30:00Z'], 0], + [['2026-09-14T00:01:00Z', '2026-09-15T23:59:00Z'], 1], + ]) { + const observations = dates.map((date, total) => ({ observed_at: new Date(date).toISOString(), total })); + const svg = renderSVG({ schema_version: 1, repository: 'shendeguize/AgentOrganon', observations }); + assert.equal((svg.match(/` checker. Mechanical success does not certify source fidelity. The translation skill also works independently without original prose; request `--explain-lines` for a detailed appendix. Private proof runs remain in ignored `.local/`. +![Observed total stars for this repository](https://shendeguize.github.io/AgentOrganon/assets/stars.svg) -The private package name is `@shendeguize/agent-organon`, version `0.1.0`. Installation modes and release publication are outside this implementation. Read [AGENTS.md](AGENTS.md) before contributing; remote publication follows the referenced Core authorization rules. +Daily observations begin when collection is enabled. Zero totals, unstars and missing samples are retained; the chart reports its update time. -Use the [self-practice and iteration protocol](docs/self-iteration.md) to compare methods, retain evidence, and complete a bounded iteration. +Source-checkout operations: [workspace files and script reference](docs/workspace-reference.md). -## License +## Contribute -MIT. +Read the repository’s agent guidance and maintenance protocols before contributing. Mechanical checks, independent review and human adoption decisions have distinct responsibilities. -The implementation of both Lean skills, their checker and current evidence now belongs to [OrganonCore](OrganonCore/lean/README.md). The outer entrypoints retain workspace-baseline resolution and forward to the corresponding Core skills; the existing checker command remains compatible. +MIT · [License](LICENSE) diff --git a/assets/banner.svg b/assets/banner.svg new file mode 100644 index 0000000..a08f3cd --- /dev/null +++ b/assets/banner.svg @@ -0,0 +1 @@ +AgentOrganon — OrganonGround agent judgments and improvements. Philosophy, methods and grounds.PHILOSOPHY · METHODS · GROUNDSAgentOrganonGround agent judgments and improvements.ORGANON / 1.0.0-rc.1 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..703cb74 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,47 @@ +# Quick start + +Use Node.js 22 or later and your chosen agent tool. These commands target the published candidate package; before publication, use the local candidate archive route below. + +## 1. Install the methods + +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 install --product agent-organon --agent codex --scope project --project /path/to/workspace --version 1.0.0-rc.1 +``` + +Replace `/path/to/workspace` with your project path. Choose `codex`, `claude`, `cursor`, `copilot`, `gemini` or `opencode` for `--agent`. Start with project installation; use `--scope global` for global installation. Installing methods does not adopt a philosophy. + +## 2. Check installation and discovery + +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 check --product agent-organon --agent codex --scope project --project /path/to/workspace +``` + +Open the agent in that project and confirm the method appears in its skill list or can be loaded explicitly. File checks establish installation integrity; actual tool support is recorded in the candidate’s validation matrix. Resolve reported project/global naming conflicts before continuing. + +## 3. Select a philosophy explicitly + +```sh +npx --yes @shendeguize/agent-organon@1.0.0-rc.1 init --product agent-organon --agent codex --scope project --project /path/to/workspace --philosophy core +``` + +Read the preview, confirm the destination and philosophical text, then repeat the command with `--apply --plan-sha256 ` using the exact `planSha256` value in that preview. A changed philosophy or instruction file requires a new preview and decision. An existing philosophy must not be silently replaced; the collection root does not select a domain. Method installation and project adoption are managed separately. + +## 4. Complete a read-only assessment + +Give the agent the actual material you want assessed and ask: + +> Use `organon-assess` with this project’s explicitly selected `PHILOSOPHY.md` as the adopted baseline. Assess the material’s grounds, applicability and possible conflicts. Return a read-only report; do not edit files or adopt a proposal. + +Check that the report identifies its baseline, reasons and limits. An assessment does not authorize philosophical revision; missing independent review must remain explicitly incomplete. + +## Offline archives and lifecycle commands + +Download and extract the archive from the corresponding GitHub Release. Every product includes `installer/organon.mjs`, which Node can run directly; only the AgentOrganon npm package registers the `organon` command. + +```sh +node /path/to/extracted/package/installer/organon.mjs install --product agent-organon --agent codex --scope project --project /path/to/workspace --from /path/to/shendeguize-agent-organon-1.0.0-rc.1.tgz +``` + +The same entrypoint provides `update`, `rollback` and `uninstall`. Check integrity before updating; user modifications or existing-file conflicts stop an update. Uninstall retains user modifications and the adopted project philosophy. + +[Understand the concepts and boundaries](https://shendeguize.github.io/AgentOrganon/understand) · [GitHub Releases](https://github.com/shendeguize/AgentOrganon/releases) diff --git a/docs/understanding.md b/docs/understanding.md new file mode 100644 index 0000000..cb4a8a1 --- /dev/null +++ b/docs/understanding.md @@ -0,0 +1,38 @@ +# Understand Organon + +## Start with three distinct objects + +**Philosophy** states adopted commitments, their meanings and conditions of application. **Skills** apply methods to tasks and remain revisable. **Tools** check and transform files; they do not decide philosophical correctness or reasons for adoption. + +## How an assessment starts + +Identify the actual input and adopted philosophy, then state the question to assess. Examine reasons, conditions and scope; distinguish contradiction from insufficient support. When considering a revision, keep the preceding baseline identifiable. Compatibility does not warrant adoption, and conflict does not by itself defeat reasons for revision. + +## When change is needed + +An ordinary assessment can end with a report. Absorption examines reasons for philosophical revision and implements it after explicit authorization. Wording review concerns expression; file management checks structure and identity. These methods have different responsibilities and do not replace one another’s judgments. + +## A map of the reading material + +- [Philosophy](https://shendeguize.github.io/AgentOrganon/philosophy): commitments, meanings and limits. +- [Rationale](https://shendeguize.github.io/AgentOrganon/rationale): arguments, alternatives and objections; no additional philosophical obligations. +- [Lean reader](https://shendeguize.github.io/AgentOrganon/lean): begin with claims, premises and boundaries, then inspect declarations and line explanations. +- Maintenance protocols: review, validation and release procedures for maintainers, in the repository’s `maintenance/` directory. + +## What Lean can establish + +Lean checks formal conclusions under given definitions and assumptions. Target `accepted`, kernel `passed` and source fidelity are different judgments; `limited`, `incomplete` and `not_applicable` retain their recorded meanings. Defining a duty does not show its fulfillment; a finite model does not establish universal real-world correctness. Readers unfamiliar with Lean can start with the claim overview and consult the paired code and explanation pages one line at a time. + +## Installation and adoption are separate + +Project installation supplies method entrypoints for that project; global installation supplies user-level entrypoints. Neither adopts a philosophy automatically. A project selects its baseline through explicit initialization; updating, rolling back or uninstalling methods does not automatically change that decision. A domain collection requires an explicit domain choice. + +## Choose a method + +| Method | Purpose | +| --- | --- | +| `organon-assess` | Assess an input under the selected philosophy. | +| `organon-absorb` | Examine reasons for philosophical revision and carry out authorized adoption. | +| `organon-principled-review` | Analyze an object under the shared method’s stated standards. | +| `organon-wording-review` | Review wording without deciding philosophical adoption. | +| `organon-philosophy` | Manage philosophy copies, versions and file operations. | diff --git a/docs/workspace-reference.md b/docs/workspace-reference.md new file mode 100644 index 0000000..b903b08 --- /dev/null +++ b/docs/workspace-reference.md @@ -0,0 +1,34 @@ +# Workspace files and scripts reference + +## Files and versions + +An adopting workspace uses a regular `PHILOSOPHY.md` and adjacent `PHILOSOPHY.lock.json`, whose `document_filename` identifies the managed file in that directory. The [format contract](../OrganonCore/skills/references/structure.md) defines the four supported frontmatter fields and stable IDs for every heading with its direct body and for introductory text. It supports unindented ATX headings such as `## Title`; other ATX forms and Setext headings are rejected. The current three-chapter Core organization is an initialization template; adopters may reorganize it. Initialization adds an empty Extensions section, identified by stable ID `extensions` rather than its title or position. + +- `format_version` identifies the supported file format. Unsupported formats stop managed writes. +- `philosophy_version` describes semantic revision, proposed by an agent and decided by the user: changed or withdrawn commitments, meanings, or applicability require major; additions preserving existing commitments require minor; meaning-preserving wording or structural maintenance requires patch. +- `core_version` records the last fully reviewed Core source version. A version outside `package.json`'s `organon.coreVersion` range produces a source-version warning, not a philosophical verdict. +- `derived_from` records export provenance. Neither provenance nor matching versions establishes a common ancestor. + +The lock stores hashes and structure for reviewed source checkpoints, plus remembered declines. It contains no old source text. Local differences from a checkpoint are expected. A declined source unit is not proposed again until its incoming state changes; the actual difference remains visible. Imports with no verifiable source checkpoint use two-way differences and never advance Core's reviewed version. + +This repository's root `PHILOSOPHY.md` is a relative symlink to `OrganonCore/PHILOSOPHY.md`, used only for reading; relative references resolve from the real source directory. There is no root derived lock. Management scripts reject writes through or over this symlink and writes to the Core source. Core changes use its explicit maintenance or absorption workflow. + +## Local use + +Use Node.js 22; there are no third-party runtime dependencies. Run these commands from this checkout, with the OrganonCore submodule present. Use absolute script paths when working from another directory. The target's parent directory must already exist. In a Git workspace, ignore `.local/` before initialization so recovery journals remain private; the scripts create the journal directory when needed. + +```sh +node scripts/check.js --source OrganonCore/PHILOSOPHY.md +node scripts/check.js --source PHILOSOPHY.md +node scripts/init.js --target /path/to/workspace/PHILOSOPHY.md +node scripts/check.js /path/to/workspace/PHILOSOPHY.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/export.md +node scripts/export.js --source /path/to/workspace/PHILOSOPHY.md --out /path/to/core-part.md --core-only +node --test tests/*.test.js +``` + +Initialization and export require absent output paths. `--core-only` removes the subtree identified by `extensions`; a missing marker stops export. The remaining text is still the adopter's text, not an official Core copy. Source identities are caller-supplied labels or stored document identities, not authenticated publisher identities. Managed operations, including reads for initialization, export, and classification, stop when an input has a pending transaction; use the reported recovery command before resuming. + +Use `organon-philosophy merge --dry-run` to request a read-only difference report. The skill invokes `classify.js`; there is no installed `organon-philosophy` executable. The skill documents candidate preparation, decisions, recovery, and application commands. Keep reports, candidates, plans, and six-part merge records in ignored `.local/iterations/merges/`. A prepared plan binds the reviewed inputs and candidate; stale inputs stop application. Preparation and successful file checks do not supply adoption authorization. + +[Maintenance and iteration protocol](../maintenance/self-iteration.md) diff --git a/installer/lib/adapters.mjs b/installer/lib/adapters.mjs new file mode 100644 index 0000000..01af3c9 --- /dev/null +++ b/installer/lib/adapters.mjs @@ -0,0 +1,13 @@ +import path from 'node:path'; + +// Native discovery paths, checked against the linked vendor documentation. +// Filesystem installation does not certify authenticated invocation/delegation. +export const AGENTS = { + codex: { project: '.agents/skills', global: '.agents/skills', instructions: 'AGENTS.md', aliases: ['.codex/skills'], command: 'codex', source: 'https://developers.openai.com/codex/skills/' }, + claude: { project: '.claude/skills', global: '.claude/skills', instructions: 'CLAUDE.md', aliases: [], command: 'claude', source: 'https://code.claude.com/docs/en/skills' }, + cursor: { project: '.cursor/skills', global: '.cursor/skills', instructions: 'AGENTS.md', aliases: ['.agents/skills', '.claude/skills'], command: 'agent', source: 'https://cursor.com/docs/skills' }, + copilot: { project: '.github/skills', global: '.copilot/skills', instructions: 'AGENTS.md', aliases: ['.agents/skills', '.claude/skills'], command: 'copilot', source: 'https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference' }, + gemini: { project: '.gemini/skills', global: '.gemini/skills', instructions: 'GEMINI.md', aliases: ['.agents/skills'], command: 'gemini', source: 'https://geminicli.com/docs/cli/skills/' }, + opencode: { project: '.opencode/skills', global: '.config/opencode/skills', instructions: 'AGENTS.md', aliases: ['.agents/skills', '.claude/skills'], command: 'opencode', source: 'https://opencode.ai/docs/skills/' }, +}; +export function discoveryRoot(agent, scope, root) { return path.join(root, AGENTS[agent][scope]); } diff --git a/installer/lib/archive.mjs b/installer/lib/archive.mjs new file mode 100644 index 0000000..29e0518 --- /dev/null +++ b/installer/lib/archive.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { listFiles, safeRelative, write } from './files.mjs'; + +// A small regular-file-only ustar implementation. Archives never create links, +// devices or executable lifecycle hooks. Reject extension records and duplicates. +const text = (buffer, start, length) => buffer.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, ''); +function octal(buffer, start, length) { + const value = text(buffer, start, length).trim(); + if (!/^[0-7]*$/.test(value)) throw new Error('Invalid tar numeric field'); + return parseInt(value || '0', 8); +} +export function extractTarball(file, destination) { + const archive = gunzipSync(fs.readFileSync(file), { maxOutputLength: 64 * 1024 * 1024 }); + const entries = []; + const names = new Set(); + let offset = 0; + for (; offset + 512 <= archive.length; ) { + const header = archive.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) break; + const expected = octal(header, 148, 8); + const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0); + if (sum !== expected) throw new Error('Invalid tar checksum'); + const prefix = text(header, 345, 155); + const rawName = `${prefix ? `${prefix}/` : ''}${text(header, 0, 100)}`; + const type = text(header, 156, 1); + if (!['', '0', '5'].includes(type)) throw new Error(`Unsupported tar entry type: ${type}`); + const name = safeRelative(rawName.replace(/\/$/, '')); + if (name !== 'package' && !name.startsWith('package/')) throw new Error('Archive must use a package/ root'); + if (names.has(name)) throw new Error(`Duplicate tar entry: ${name}`); + names.add(name); + const size = octal(header, 124, 12); + offset += 512; + if (offset + size > archive.length) throw new Error('Truncated tar entry'); + if (type === '5' && size !== 0) throw new Error('Tar directory has content'); + if (type !== '5') entries.push([name, archive.subarray(offset, offset + size)]); + offset += Math.ceil(size / 512) * 512; + } + if (archive.subarray(offset).some(byte => byte !== 0)) throw new Error('Invalid tar trailer'); + for (const [name, bytes] of entries) write(path.join(destination, name), bytes); + return path.join(destination, 'package'); +} +export function createTarball(root, destination) { + const chunks = []; + for (const name of listFiles(root)) { + const full = `package/${name}`; + let basename = full, prefix = ''; + if (Buffer.byteLength(full) > 100) { + const split = full.lastIndexOf('/'); + prefix = full.slice(0, split); basename = full.slice(split + 1); + } + if (Buffer.byteLength(prefix) > 155 || Buffer.byteLength(basename) > 100) throw new Error(`Path exceeds ustar limits: ${full}`); + const bytes = fs.readFileSync(path.join(root, name)); + const header = Buffer.alloc(512); + const field = (value, start, length) => header.write(value, start, length, 'ascii'); + const number = (value, start, length) => field(`${value.toString(8).padStart(length - 1, '0')}\0`, start, length); + header.write(basename, 0, 100, 'utf8'); + number(name === 'installer/organon.mjs' ? 0o755 : 0o644, 100, 8); + number(0, 108, 8); number(0, 116, 8); number(bytes.length, 124, 12); number(0, 136, 12); + field(' ', 148, 8); field('0', 156, 1); field('ustar\0', 257, 6); field('00', 263, 2); + header.write(prefix, 345, 155, 'utf8'); + const sum = header.reduce((total, byte) => total + byte, 0); + field(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8); + chunks.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + chunks.push(Buffer.alloc(1024)); + write(destination, gzipSync(Buffer.concat(chunks), { level: 9 })); + return destination; +} diff --git a/installer/lib/files.mjs b/installer/lib/files.mjs new file mode 100644 index 0000000..05ce01a --- /dev/null +++ b/installer/lib/files.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +export const sha256 = value => createHash('sha256').update(value).digest('hex'); +export const json = file => JSON.parse(fs.readFileSync(file, 'utf8')); +export const exists = file => { try { fs.lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } }; +export function write(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, value); +} +export const serialize = value => `${JSON.stringify(value, null, 2)}\n`; +export function safeRelative(value) { + if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || value.split('/').some(part => !part || part === '.' || part === '..') || /^[A-Za-z]:/.test(value)) throw new Error(`Unsafe relative path: ${value}`); + return value; +} +export function within(root, file) { + const relative = path.relative(root, file); + if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) throw new Error(`Path escapes managed root: ${file}`); + return file; +} +export function noSymlinkAncestors(file) { + let cursor = path.resolve(file); + for (;;) { + if (exists(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Symbolic link destination is not writable: ${cursor}`); + const parent = path.dirname(cursor); + if (parent === cursor) return; + cursor = parent; + } +} +export function listFiles(root, prefix = '') { + const result = []; + for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const name = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) result.push(...listFiles(root, name)); + else if (entry.isFile()) result.push(name); + else throw new Error(`Unsupported file type: ${name}`); + } + return result.sort(); +} diff --git a/installer/lib/lifecycle.mjs b/installer/lib/lifecycle.mjs new file mode 100644 index 0000000..9c89e44 --- /dev/null +++ b/installer/lib/lifecycle.mjs @@ -0,0 +1,277 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { AGENTS, discoveryRoot } from './adapters.mjs'; +import { PRODUCTS, openPackage, verifyPackage } from './package.mjs'; +import { exists, json, serialize, sha256, write, within, noSymlinkAncestors, listFiles } from './files.mjs'; +import { locked, recover, transact, matches } from './transaction.mjs'; + +function context(options) { + if (!Object.hasOwn(PRODUCTS, options.product)) throw new Error('--product agent-organon|core|advised is required'); + if (!Object.hasOwn(AGENTS, options.agent)) throw new Error(`--agent ${Object.keys(AGENTS).join('|')} is required`); + if (!['project', 'global'].includes(options.scope)) throw new Error('--scope project|global is required'); + if (options.scope === 'project' && !options.project) throw new Error('--project is required for project scope'); + const requestedRoot = options.scope === 'global' ? os.homedir() : path.resolve(options.project); + if (!exists(requestedRoot) || !fs.statSync(requestedRoot).isDirectory()) throw new Error(`Installation root must exist: ${requestedRoot}`); + const root = fs.realpathSync(requestedRoot); + const store = path.join(root, '.organon'); + noSymlinkAncestors(store); + return { ...options, root, store, stateFile: path.join(store, 'installations.json') }; +} +function stateOf(ctx) { + noSymlinkAncestors(ctx.stateFile); + const state = exists(ctx.stateFile) ? json(ctx.stateFile) : { schema: 1, products: {} }; + if (state.schema !== 1 || !state.products || typeof state.products !== 'object') throw new Error('Invalid installation state'); + for (const [product, entry] of Object.entries(state.products)) { + if (!Object.hasOwn(PRODUCTS, product)) throw new Error('Unknown product in installation state'); + for (const candidate of [entry, entry.previous].filter(Boolean)) { + within(path.join(ctx.store, 'packages', product), candidate.directory); + noSymlinkAncestors(candidate.directory); + if (!/^[a-f0-9]{64}$/.test(candidate.manifestHash)) throw new Error('Invalid installed manifest identity'); + } + for (const [agent, discovery] of Object.entries(entry.discovery ?? {})) { + if (!Object.hasOwn(AGENTS, agent)) throw new Error('Unknown agent in installation state'); + for (const item of discovery) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.skill) || item.file !== path.join(discoveryRoot(agent, ctx.scope, ctx.root), item.skill, 'SKILL.md')) throw new Error('Invalid installed discovery path'); + } + } + } + return state; +} +function assertCurrent(entry) { + const manifest = verifyPackage(entry.directory); + if (manifest.manifestHash !== entry.manifestHash) throw new Error('Installed manifest was modified'); + for (const discovery of Object.values(entry.discovery)) { + for (const item of discovery) if (!matches(item.file, item.sha256)) throw new Error(`Installed skill was modified or removed: ${item.file}`); + } +} +function renderSkill(source, destination, directory) { + const payload = path.join(directory, 'payload'); + const text = fs.readFileSync(source, 'utf8'); + const canonical = path.dirname(source); + let rendered = text.replace(/\]\(([^\s)]+)\)/g, (whole, href) => { + if (/^(?:[a-z]+:|#|\/)/i.test(href)) return whole; + const [relative, fragment] = href.split('#'); + const target = path.resolve(canonical, decodeURIComponent(relative)); + return `](${path.relative(path.dirname(destination), target).split(path.sep).map(part => encodeURIComponent(part)).join('/')}${fragment ? `#${fragment}` : ''})`; + }); + // Keep the caller's cwd unchanged. Script roots are explicit, including the + // management skill's formerly repository-relative command examples. + rendered = rendered.replace(/node (?:\/)?scripts\/([\w/-]+\.js)/g, (_, script) => `node "${path.join(payload, 'scripts', script)}"`); + rendered = rendered.replaceAll('', payload); + const end = rendered.indexOf('\n---', 4); + const note = `\n\nInstalled runtime directory: \`${payload}\`. Canonical skill directory: \`${canonical}\`. Resolve any remaining repository-relative command or resource from those directories; preserve the caller's workspace for philosophical selection. This installation contains non-Lean methods only.\n`; + return rendered.slice(0, end + 4) + note + rendered.slice(end + 4); +} +function discoveryFor(ctx, directory, manifest, agents) { + const discovery = {}; + const operations = []; + for (const agent of agents) { + discovery[agent] = []; + for (const skill of manifest.skills) { + const file = path.join(discoveryRoot(agent, ctx.scope, ctx.root), skill.name, 'SKILL.md'); + const content = renderSkill(path.join(directory, skill.path), file, directory); + discovery[agent].push({ file, sha256: sha256(content), sourceSha256: manifest.files.find(entry => entry.path === skill.path).sha256, skill: skill.name }); + operations.push({ file, content }); + } + } + return { discovery, operations }; +} +function collisionCheck(ctx, operations, existing) { + const owned = new Map(Object.values(existing?.discovery ?? {}).flat().map(entry => [entry.file, entry.sha256])); + for (const { file } of operations) { + noSymlinkAncestors(file); + if (exists(file) && (!owned.has(file) || !matches(file, owned.get(file)))) throw new Error(`Discovery path conflict: ${file}`); + } +} +function aliases(ctx, entry) { + const warnings = []; + for (const [agent, discovered] of Object.entries(entry.discovery)) { + for (const skill of discovered) { + for (const alias of AGENTS[agent].aliases) { + const file = path.join(ctx.root, alias, skill.skill, 'SKILL.md'); + if (file !== skill.file && exists(file)) { + const ownedAlias = Object.values(entry.discovery).flat().find(item => item.file === file); + warnings.push({ type: 'alias-discovery', agent, skill: skill.skill, file, sameManagedPayload: Boolean(ownedAlias && matches(file, ownedAlias.sha256)), resolution: 'Inspect both discovery paths. Retain the desired integration and explicitly uninstall an unwanted managed integration; agent precedence is not assumed.' }); + } + } + const otherRoot = ctx.scope === 'project' ? os.homedir() : ctx.project && path.resolve(ctx.project); + const otherScope = ctx.scope === 'project' ? 'global' : 'project'; + if (otherRoot) { + const file = path.join(discoveryRoot(agent, otherScope, otherRoot), skill.skill, 'SKILL.md'); + if (file !== skill.file && exists(file)) warnings.push({ type: 'cross-scope-discovery', agent, skill: skill.skill, file, resolution: 'Compare the project and global installations. Explicitly uninstall the unwanted scope; no scope is silently preferred.' }); + } + } + } + return warnings; +} + +export async function install(options, { update = false, bundled } = {}) { + const ctx = context(options); + if (ctx.product === 'advised' && ctx.domain !== 'SoftwareEngineering') throw new Error('AdvisedOrganons requires explicit --domain SoftwareEngineering'); + const source = await openPackage({ ...ctx, bundled }); + try { + return locked(ctx.store, () => { + const recovered = recover(ctx.store, ctx.root); + const state = stateOf(ctx); + const existing = state.products[ctx.product]; + if (existing) assertCurrent(existing); + if (update && !existing) throw new Error('Product is not installed; use install first'); + if (!update && existing && existing.manifestHash !== source.manifest.manifestHash) throw new Error('Different content is already installed; use update'); + if (existing?.manifestHash === source.manifest.manifestHash && existing.discovery[ctx.agent]) return { action: 'unchanged', product: ctx.product, version: existing.version, directory: existing.directory, recovered, warnings: aliases(ctx, existing) }; + const directory = path.join(ctx.store, 'packages', ctx.product, `${source.manifest.version}-${source.manifest.manifestHash.slice(0, 16)}`); + noSymlinkAncestors(directory); + if (!exists(directory)) { + const staging = `${directory}.staging-${process.pid}`; + fs.mkdirSync(path.dirname(staging), { recursive: true }); + fs.cpSync(source.directory, staging, { recursive: true, dereference: false, errorOnExist: true, force: false }); + verifyPackage(staging); fs.renameSync(staging, directory); + } + const manifest = verifyPackage(directory); + const agents = [...new Set([...Object.keys(existing?.discovery ?? {}), ctx.agent])].sort(); + const { discovery, operations } = discoveryFor(ctx, directory, manifest, agents); + collisionCheck(ctx, operations, existing); + const nextFiles = new Set(operations.map(entry => entry.file)); + for (const item of Object.values(existing?.discovery ?? {}).flat()) if (!nextFiles.has(item.file)) operations.push({ file: item.file, content: null }); + const entry = { version: manifest.version, manifestHash: manifest.manifestHash, directory, discovery, domain: ctx.domain ?? existing?.domain ?? null, previous: existing && existing.manifestHash !== manifest.manifestHash ? { version: existing.version, manifestHash: existing.manifestHash, directory: existing.directory } : existing?.previous ?? null }; + state.products[ctx.product] = entry; + operations.push({ file: ctx.stateFile, content: serialize(state) }); + transact(ctx.store, ctx.root, operations); + const retained = []; + const obsolete = existing?.previous; + if (obsolete && obsolete.directory !== directory && obsolete.directory !== entry.previous?.directory) { + try { + if (verifyPackage(obsolete.directory).manifestHash !== obsolete.manifestHash) retained.push(obsolete.directory); + else fs.rmSync(obsolete.directory, { recursive: true }); + } catch { retained.push(obsolete.directory); } + } + return { action: update ? 'updated' : 'installed', product: ctx.product, version: manifest.version, directory, discovery, recovered, retained, warnings: aliases(ctx, entry), capabilityValidation: 'Filesystem content verified; authenticated agent invocation, resource permissions and delegation require separate tests.' }; + }); + } finally { source.close(); } +} + +export function check(options) { + const ctx = context(options); + if (exists(path.join(ctx.store, 'transaction.json'))) throw new Error('Interrupted installation; rerun its lifecycle command to recover before checking'); + const entry = stateOf(ctx).products[ctx.product]; + if (!entry?.discovery[ctx.agent]) throw new Error('Product is not installed for this agent and scope'); + assertCurrent(entry); + const command = AGENTS[ctx.agent].command; + const result = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10000, windowsHide: true }); + return { passed: true, validationScope: 'package-integrity-and-discovery-files', capabilityValidationComplete: false, product: ctx.product, version: entry.version, directory: entry.directory, discovery: entry.discovery[ctx.agent], warnings: aliases(ctx, entry), agent: { command, executableAvailable: !result.error && result.status === 0, authentication: 'not-checked', invocation: 'not-checked', resourcePermissions: 'not-checked', delegation: 'not-checked' } }; +} + +export function rollback(options) { + const ctx = context(options); + return locked(ctx.store, () => { + const recovered = recover(ctx.store, ctx.root); + const state = stateOf(ctx); const entry = state.products[ctx.product]; + if (!entry?.previous) throw new Error('No previous complete installation is available'); + assertCurrent(entry); + const previous = verifyPackage(entry.previous.directory); + if (previous.manifestHash !== entry.previous.manifestHash) throw new Error('Previous installation was modified'); + const { discovery, operations } = discoveryFor(ctx, entry.previous.directory, previous, Object.keys(entry.discovery)); + collisionCheck(ctx, operations, entry); + const nextFiles = new Set(operations.map(item => item.file)); + for (const item of Object.values(entry.discovery).flat()) if (!nextFiles.has(item.file)) operations.push({ file: item.file, content: null }); + state.products[ctx.product] = { ...entry.previous, discovery, domain: entry.domain, previous: { version: entry.version, manifestHash: entry.manifestHash, directory: entry.directory } }; + operations.push({ file: ctx.stateFile, content: serialize(state) }); + transact(ctx.store, ctx.root, operations); + return { action: 'rolled-back', product: ctx.product, version: previous.version, recovered }; + }); +} + +export function uninstall(options) { + const ctx = context(options); + return locked(ctx.store, () => { + const recovered = recover(ctx.store, ctx.root); + const state = stateOf(ctx); const entry = state.products[ctx.product]; + if (!entry?.discovery[ctx.agent]) return { action: 'unchanged', recovered }; + const retained = []; + const operations = []; + for (const item of entry.discovery[ctx.agent]) { + if (matches(item.file, item.sha256)) operations.push({ file: item.file, content: null }); + else if (exists(item.file)) retained.push(item.file); + } + delete entry.discovery[ctx.agent]; + const removeProduct = !Object.keys(entry.discovery).length; + if (removeProduct) delete state.products[ctx.product]; + operations.push({ file: ctx.stateFile, content: serialize(state) }); + transact(ctx.store, ctx.root, operations); + if (removeProduct) { + // Retain content whenever a discovery copy was changed, so its references + // do not become dangling. Payload edits also prevent recursive deletion. + for (const candidate of [entry, entry.previous].filter(Boolean)) { + try { + const manifest = verifyPackage(candidate.directory); + if (manifest.manifestHash !== candidate.manifestHash || retained.length) retained.push(candidate.directory); + else fs.rmSync(candidate.directory, { recursive: true }); + } catch { retained.push(candidate.directory); } + } + } + return { action: 'uninstalled', product: ctx.product, agent: ctx.agent, retained, recovered, adoption: 'unchanged' }; + }); +} + +export async function initialize(options) { + const ctx = context(options); + if (!options.project) throw new Error('--project is required for explicit project adoption'); + const project = fs.realpathSync(options.project); + const entry = stateOf(ctx).products[ctx.product]; + if (!entry?.discovery[ctx.agent]) throw new Error('Install this product for the selected agent/scope before init'); + assertCurrent(entry); + if (!['core', 'SoftwareEngineering'].includes(options.philosophy)) throw new Error('--philosophy core|SoftwareEngineering is required; installation is not adoption'); + if (options.philosophy === 'SoftwareEngineering' && (ctx.product !== 'advised' || options.domain !== 'SoftwareEngineering')) throw new Error('Select --product advised --domain SoftwareEngineering to adopt this domain'); + const source = path.join(entry.directory, 'payload', options.philosophy === 'core' ? 'OrganonCore/PHILOSOPHY.md' : 'AdvisedOrganons/SoftwareEngineering/PHILOSOPHY.md'); + const target = path.join(project, 'PHILOSOPHY.md'); + const lock = path.join(project, 'PHILOSOPHY.lock.json'); + const instructions = path.join(project, AGENTS[ctx.agent].instructions); + const adoption = path.join(project, '.organon/adoption.json'); + for (const file of [target, lock, instructions, adoption]) noSymlinkAncestors(file); + if (exists(target) || exists(lock) || exists(adoption)) throw new Error('Existing philosophy/adoption is preserved; use the philosophical review workflow for revision'); + const sourceId = options.philosophy === 'core' ? 'https://github.com/shendeguize/OrganonCore' : 'https://github.com/shendeguize/AdvisedOrganons/SoftwareEngineering'; + const block = '\n\nRead [PHILOSOPHY.md](PHILOSOPHY.md) as this workspace\'s adopted philosophy. Preserve its path and real reference directory through Organon delegation. Installation changes do not authorize philosophical revision.\n\n'; + const current = exists(instructions) ? fs.readFileSync(instructions, 'utf8') : ''; + if (current.includes('/g)].map(m => m[1]); + if (JSON.stringify(ids(en)) !== JSON.stringify(ids(zh))) errors.push(`${relative}: EN/ZH stable IDs differ`); + } + return { status: errors.length ? 'failed' : 'passed', checked, errors }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(() => { + const args = parseArgs(); const result = checkContent(args.repo || '.'); + if (args.manifest) { result.source_digest = readJSON(args.manifest).source_digest; result.gate = 'content'; result.product = args.product; } + if (args.out) writeJSON(args.out, result); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; +}); diff --git a/scripts/release/evidence.mjs b/scripts/release/evidence.mjs new file mode 100644 index 0000000..c79b1f7 --- /dev/null +++ b/scripts/release/evidence.mjs @@ -0,0 +1,137 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { readJSON, confined, sha256, digest } from './lib.mjs'; +import { validateReceipt } from '../agents/review.mjs'; + +export function verifyReference(root, reference) { + if (!reference || typeof reference.file !== 'string' || !/^[a-f0-9]{64}$/.test(reference.sha256 || '')) throw new Error('Invalid evidence reference'); + const file = confined(root, reference.file); + if (!fs.statSync(file).isFile() || sha256(fs.readFileSync(file)) !== reference.sha256) throw new Error('Evidence artifact checksum mismatch: ' + reference.file); + return file; +} +// Only explicit {file, sha256} objects form artifact edges. Textual output is never interpreted as an artifact directive. +export function discoverArtifactReferences(value) { + const references = []; + const visit = current => { + if (!current || typeof current !== 'object') return; + if (!Array.isArray(current) && Object.hasOwn(current, 'file') && Object.hasOwn(current, 'sha256')) { + if (typeof current.file !== 'string' || !/^[a-f0-9]{64}$/.test(current.sha256 || '')) throw new Error('Malformed nested artifact reference'); + references.push({ file: current.file, sha256: current.sha256 }); + } + for (const child of Object.values(current)) visit(child); + }; + visit(value); return references; +} +export function artifactChildren(root, reference) { + const file = verifyReference(root, reference); + return reference.file.endsWith('.json') ? discoverArtifactReferences(readJSON(file)) : []; +} +export function evidenceRoots(manifest) { return discoverArtifactReferences(manifest); } +export function collectEvidenceClosure(manifest, root) { + const pending = evidenceRoots(manifest), visited = new Map(); + while (pending.length) { + const ref = pending.shift(), previous = visited.get(ref.file); + if (previous) { if (previous.sha256 !== ref.sha256) throw new Error('Conflicting identities for evidence file: ' + ref.file); continue; } + if (visited.size >= 10000) throw new Error('Evidence closure exceeds file limit'); + visited.set(ref.file, ref); pending.push(...artifactChildren(root, ref)); + } + return [...visited.values()].sort((a, b) => a.file.localeCompare(b.file)); +} +export async function fetchEvidenceClosure(manifest, root, fetchArtifact) { + const pending = evidenceRoots(manifest), visited = new Map(); + while (pending.length) { + const ref = pending.shift(), previous = visited.get(ref.file); + if (previous) { if (previous.sha256 !== ref.sha256) throw new Error('Conflicting identities for evidence file: ' + ref.file); continue; } + if (visited.size >= 10000) throw new Error('Evidence closure exceeds file limit'); + // Confinement is checked before a caller is allowed to download this path. + confined(root, ref.file); + await fetchArtifact(ref); + visited.set(ref.file, ref); pending.push(...artifactChildren(root, ref)); + } + return [...visited.values()].sort((a, b) => a.file.localeCompare(b.file)); +} +export function validateApprovedAgentReport(root, approved) { + const unreviewedFile = verifyReference(root, approved.unreviewed_report); + const receipt = readJSON(verifyReference(root, approved.review_receipt)); + const expected = { ...validateReceipt(root, unreviewedFile, receipt), unreviewed_report: approved.unreviewed_report, review_receipt: approved.review_receipt }; + if (!isDeepStrictEqual(approved, expected)) throw new Error('Approved agent summary differs from its independently reviewed exact original'); + return expected; +} + +export const INVENTORY_FILE = 'trusted-executions.json'; +const COORDINATOR = 'shendeguize/AgentOrganon'; +const SEAL_WORKFLOW = `${COORDINATOR}/.github/workflows/seal.yml@refs/heads/release/1.0.0`; +export function assertSealRole(manifest, environment = process.env) { + if (environment.GITHUB_ACTIONS !== 'true' || environment.GITHUB_EVENT_NAME !== 'workflow_dispatch' || + environment.GITHUB_REPOSITORY !== COORDINATOR || environment.GITHUB_REF !== 'refs/heads/release/1.0.0' || + environment.GITHUB_WORKFLOW_REF !== SEAL_WORKFLOW || environment.RUNNER_ENVIRONMENT !== 'github-hosted' || + environment.GITHUB_SHA !== manifest.sources['agent-organon'].commit || !/^[1-9]\d*$/.test(environment.GITHUB_RUN_ID || '')) { + throw new Error('Sealing requires the exact protected hosted coordinator workflow'); + } +} +export function readTrustedInventory(manifest, root, { required = false, sealing = false, environment = process.env } = {}) { + if (!manifest.trusted_executions) { + if (required || sealing || manifest.state === 'validated') throw new Error('Missing trusted execution inventory'); + return null; + } + if (manifest.trusted_executions.file !== INVENTORY_FILE) throw new Error('Unexpected trusted inventory path'); + const inventory = readJSON(verifyReference(root, manifest.trusted_executions)); + const collector = inventory.collector; + if (inventory.schema_version !== 1 || inventory.source_digest !== manifest.source_digest || inventory.artifact_digest !== digest(manifest.artifacts) || + collector?.repository !== COORDINATOR || collector.workflow !== SEAL_WORKFLOW || collector.source_commit !== manifest.sources['agent-organon'].commit || + !/^[1-9]\d*$/.test(collector.run_id || '') || !/^[1-9]\d*$/.test(inventory.candidate_run || '') || + !Array.isArray(inventory.runs) || !Array.isArray(inventory.files)) throw new Error('Invalid trusted execution inventory identity'); + if (sealing) { + assertSealRole(manifest, environment); + if (collector.run_id !== environment.GITHUB_RUN_ID || collector.run_attempt !== environment.GITHUB_RUN_ATTEMPT) throw new Error('Inventory belongs to a different seal execution'); + } + const runs = new Map(); + for (const run of inventory.runs) { + if (!/^[1-9]\d*$/.test(run.run_id || '') || !/^[1-9]\d*$/.test(run.run_attempt || '') || runs.has(run.run_id) || run.source_commit !== collector.source_commit || + run.repository !== COORDINATOR || !['candidate.yml', 'agent-e2e.yml'].includes(run.workflow) || run.conclusion !== 'success') throw new Error('Invalid trusted workflow run'); + runs.set(run.run_id, run); + } + if (runs.get(inventory.candidate_run)?.workflow !== 'candidate.yml' || [...runs.values()].filter(run => run.workflow === 'candidate.yml').length !== 1) throw new Error('Missing unique trusted candidate run'); + const entries = new Map(); + for (const item of inventory.files) { + confined(root, item.path); + const run = runs.get(item.run_id); + if (entries.has(item.path) || item.path === INVENTORY_FILE || item.path === 'release-manifest.json' || !/^[a-f0-9]{64}$/.test(item.sha256 || '') || + !run || item.workflow !== run.workflow || item.run_attempt !== run.run_attempt || !/^[1-9]\d*$/.test(item.artifact_id || '') || !/^sha256:[a-f0-9]{64}$/.test(item.artifact_digest || '') || !item.artifact?.endsWith('-' + run.run_attempt) || typeof item.artifact !== 'string' || !item.artifact) throw new Error('Invalid trusted artifact inventory entry'); + entries.set(item.path, item); + } + return { inventory, entries }; +} +export function requireTrustedReference(root, trusted, reference, workflow, runID) { + verifyReference(root, reference); + const entry = trusted.entries.get(reference.file); + if (!entry || entry.sha256 !== reference.sha256 || entry.workflow !== workflow || (runID && entry.run_id !== String(runID))) throw new Error('Evidence is not from the required trusted execution: ' + reference.file); + return entry; +} +export function validateTrustedEvidence(manifest, root, options = {}) { + const trusted = readTrustedInventory(manifest, root, options); + if (!trusted) return null; + const candidate = trusted.inventory.candidate_run; + for (const ref of Object.values(manifest.artifacts || {})) requireTrustedReference(root, trusted, ref, 'candidate.yml', candidate); + const packages = new Map(Object.values(manifest.artifacts || {}).map(ref => [ref.file, ref.sha256])); + for (const ref of manifest.evidence || []) { + const report = readJSON(verifyReference(root, ref)); + if (report.gate !== 'agents') { + if (!['content', 'site', 'package', 'github', 'install'].includes(report.gate)) throw new Error('Unexpected mechanical evidence gate'); + for (const child of collectEvidenceClosure({ evidence: [ref] }, root)) requireTrustedReference(root, trusted, child, 'candidate.yml', candidate); + continue; + } + const original = readJSON(verifyReference(root, report.unreviewed_report)); + if (original.ci?.source_commit !== manifest.sources['agent-organon'].commit || !original.ci?.run_id) throw new Error('Agent execution CI identity mismatch'); + const run = String(original.ci.run_id); + if (trusted.inventory.runs.find(item => item.run_id === run)?.run_attempt !== String(original.ci.run_attempt)) throw new Error('Agent execution attempt differs from trusted artifact origin'); + // Raw output, inputs, indices and every captured attachment must share the original run. + // Only the exact release packages referenced by inputs belong to the candidate run. + for (const child of collectEvidenceClosure({ evidence: [report.unreviewed_report] }, root)) { + const isPackage = packages.get(child.file) === child.sha256; + requireTrustedReference(root, trusted, child, isPackage ? 'candidate.yml' : 'agent-e2e.yml', isPackage ? candidate : run); + } + } + return trusted; +} diff --git a/scripts/release/governance.mjs b/scripts/release/governance.mjs new file mode 100644 index 0000000..3971e35 --- /dev/null +++ b/scripts/release/governance.mjs @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, gh, digest, readJSON, writeJSON, parseArgs, requireValue, main } from './lib.mjs'; +import { inspectSecurity } from './security.mjs'; + +// Keep the audit credential out of site builders, npm and other child processes. +// Only the read-only GitHub API adapter below receives it. +const auditToken = process.env.GOVERNANCE_AUDIT_TOKEN; +delete process.env.GOVERNANCE_AUDIT_TOKEN; +function auditAPI(args, body) { + if (process.env.GITHUB_ACTIONS === 'true' && !auditToken) throw new Error('GOVERNANCE_AUDIT_TOKEN is required to inspect complete repository protections'); + return gh(args, body, auditToken ? { ...process.env, GH_TOKEN: auditToken } : process.env); +} + +export const REQUIRED_CHECK = 'Repository / verify'; +export const branchRules = () => ({ name: 'Organon protected branches', target: 'branch', enforcement: 'active', bypass_actors: [], + conditions: { ref_name: { include: ['refs/heads/main', 'refs/heads/dev*', 'refs/heads/dev*/**/*', 'refs/heads/release*', 'refs/heads/release*/**/*'], exclude: [] } }, + rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }, { type: 'pull_request', parameters: { + required_approving_review_count: 0, dismiss_stale_reviews_on_push: true, require_code_owner_review: false, + require_last_push_approval: false, required_review_thread_resolution: true, + } }, { type: 'required_status_checks', parameters: { + strict_required_status_checks_policy: true, required_status_checks: [{ context: REQUIRED_CHECK, integration_id: 15368 }], + } }], +}); +export const tagRules = () => ({ name: 'Organon immutable version tags', target: 'tag', enforcement: 'active', bypass_actors: [], + conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } }, rules: [{ type: 'deletion' }, { type: 'update' }, { type: 'non_fast_forward' }], +}); +function comparableRules(value) { + const conditions = structuredClone(value.conditions); + for (const key of ['include', 'exclude']) conditions?.ref_name?.[key]?.sort(); + const rules = structuredClone(value.rules); + for (const rule of rules) { + const p = rule.parameters; + if (rule.type === 'pull_request' && p) { + if (Array.isArray(p.required_reviewers) && p.required_reviewers.length === 0) delete p.required_reviewers; + if (p.ignore_approvals_from_contributors === false) delete p.ignore_approvals_from_contributors; + // GitHub adds this true default when creating the reviewed zero-approval rule. + // Retain it; an explicitly disabled value is not equivalent. + if (p.require_extra_approval_for_unattributed_changes === undefined) p.require_extra_approval_for_unattributed_changes = true; + if (Array.isArray(p.allowed_merge_methods) && [...p.allowed_merge_methods].sort().join() === 'merge,rebase,squash') delete p.allowed_merge_methods; + } + if (rule.type === 'required_status_checks' && p) { + if (p.do_not_enforce_on_create === false) delete p.do_not_enforce_on_create; + p.required_status_checks?.sort((a, b) => digest(a).localeCompare(digest(b))); + } + } + rules.sort((a, b) => a.type.localeCompare(b.type)); + return { target: value.target, enforcement: value.enforcement, bypass_actors: value.bypass_actors, conditions, rules }; +} +export const sameRules = (actual, desired) => Array.isArray(actual.bypass_actors) && digest(comparableRules(actual)) === digest(comparableRules(desired)); +export function allowedRepository(repository) { + if (!Object.values(PRODUCTS).some(p => `shendeguize/${p.repo}` === repository)) throw new Error('Unexpected repository'); + return repository; +} +export async function inspect(repository, api = gh) { + allowedRepository(repository); + const listed = api([`repos/${repository}/rulesets`]); + const actual = listed.map(rule => api([`repos/${repository}/rulesets/${rule.id}`])); + const bypassEvidence = []; + for (const rule of actual) { + if (Array.isArray(rule.bypass_actors)) { + bypassEvidence.push({ id: rule.id, source: 'rest', count: rule.bypass_actors.length }); + continue; + } + // REST omits actor details for read-only callers. GraphQL exposes the total + // count even when individual actor nodes are hidden. Never count visible nodes. + if (typeof rule.node_id !== 'string' || !rule.node_id) continue; + const [owner, name] = repository.split('/'); + const response = api(['graphql'], { query: 'query($owner: String!, $name: String!, $databaseId: Int!) { repository(owner: $owner, name: $name) { nameWithOwner ruleset(databaseId: $databaseId) { __typename id databaseId source { __typename ... on Repository { nameWithOwner } } bypassActors(first: 1) { totalCount } } } }', variables: { owner, name, databaseId: rule.id } }); + const repo = response?.data?.repository, node = repo?.ruleset, count = node?.bypassActors?.totalCount; + if (response?.errors?.length || repo?.nameWithOwner !== repository || node?.__typename !== 'RepositoryRuleset' || node?.id !== rule.node_id || node?.databaseId !== rule.id || node?.source?.__typename !== 'Repository' || node?.source?.nameWithOwner !== repository || !Number.isSafeInteger(count) || count < 0) throw new Error('Cannot establish complete ruleset bypass count'); + bypassEvidence.push({ id: rule.id, node_id: rule.node_id, source: 'graphql-total-count', count }); + if (count === 0) rule.bypass_actors = []; + } + const expected = [branchRules(), tagRules()]; + const missing = expected.filter(rule => !actual.some(item => item.name === rule.name && sameRules(item, rule))).map(rule => rule.name); + return { status: missing.length ? 'failed' : 'passed', repository, missing, bypass_evidence: bypassEvidence, rulesets: actual.map(item => ({ id: item.id, name: item.name, enforcement: item.enforcement })) }; +} +export async function inspectReleaseGovernance(repository, api = auditAPI) { + const rules = await inspect(repository, api); + const security = inspectSecurity(repository, api); + return { ...rules, status: rules.status === 'passed' && security.status === 'passed' ? 'passed' : 'failed', security }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const options = parseArgs(); const mode = options._[0] || 'check'; + const repository = allowedRepository(requireValue(options, 'repository')); + if (mode === 'plan') { + const result = { repository, rulesets: [branchRules(), tagRules()], required_check: REQUIRED_CHECK }; + if (options.out) writeJSON(options.out, result); else console.log(JSON.stringify(result, null, 2)); + } else if (mode === 'apply') { + const ref = requireValue(options, 'validated-ref'); + if (!/^[0-9a-f]{40}$/.test(ref)) throw new Error('A full validated commit is required'); + const checks = gh([`repos/${repository}/commits/${ref}/check-runs`]).check_runs; + if (!checks.some(check => check.name === REQUIRED_CHECK && check.conclusion === 'success' && check.app?.slug === 'github-actions')) throw new Error('Required check has not actually succeeded on the supplied revision'); + const existing = gh([`repos/${repository}/rulesets`]); + for (const rule of [branchRules(), tagRules()]) { + const match = existing.find(item => item.name === rule.name); + gh([`repos/${repository}/rulesets${match ? `/${match.id}` : ''}`, '--method', match ? 'PUT' : 'POST'], rule); + } + const result = await inspect(repository); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; + } else if (mode === 'check') { + const result = await inspectReleaseGovernance(repository); + if (options.manifest) { const manifest = readJSON(options.manifest); result.gate = 'github'; result.product = Object.keys(PRODUCTS).find(k => `shendeguize/${PRODUCTS[k].repo}` === repository); result.source_digest = manifest.source_digest; } + if (options.out) writeJSON(options.out, result); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; + } else throw new Error(`Unknown governance command: ${mode}`); +}); diff --git a/scripts/release/install-matrix.mjs b/scripts/release/install-matrix.mjs new file mode 100644 index 0000000..80cfe09 --- /dev/null +++ b/scripts/release/install-matrix.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, requireValue, readJSON, writeJSON, digest, sha256, main } from './lib.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +main(() => { + const args = parseArgs(); + const files = fs.readdirSync(path.join(root, 'tests')).filter(name => /^(installer|package).*\.test\.mjs$/.test(name)).sort().map(name => path.join(root, 'tests', name)); + const result = spawnSync(process.execPath, ['--test', ...files], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, + env: { ...process.env, ...(args.manifest ? { ORGANON_TEST_CANDIDATE_MANIFEST: path.resolve(args.manifest) } : {}) } }); + if (args.manifest) { + const manifest = readJSON(args.manifest), out = path.resolve(requireValue(args, 'out')); + if (process.env.GITHUB_ACTIONS !== 'true' || process.env.RUNNER_ENVIRONMENT !== 'github-hosted' || process.env.GITHUB_SHA !== manifest.sources['agent-organon'].commit) throw new Error('Release lifecycle evidence requires the exact source on a hosted runner'); + fs.mkdirSync(path.dirname(out), { recursive: true }); + const raw = `${out}.tap`; fs.writeFileSync(raw, `${result.stdout || ''}\n${result.stderr || ''}`, { flag: 'wx' }); + writeJSON(out, { gate: 'install', status: result.status === 0 ? 'passed' : 'failed', platform: process.platform, lifecycle: result.status === 0 ? 'complete' : 'incomplete', + source_digest: manifest.source_digest, artifact_digest: digest(manifest.artifacts), node: process.version, + ci: { run_id: process.env.GITHUB_RUN_ID, source_commit: process.env.GITHUB_SHA }, + raw_response: { file: path.relative(path.dirname(path.resolve(args.manifest)), raw).split(path.sep).join('/'), sha256: sha256(fs.readFileSync(raw)) } }); + } + process.stdout.write(result.stdout || ''); process.stderr.write(result.stderr || ''); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +}); diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs new file mode 100644 index 0000000..011781f --- /dev/null +++ b/scripts/release/lib.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; + +export const PRODUCTS = Object.freeze({ + core: { repo: 'OrganonCore', directory: 'OrganonCore', package: '@shendeguize/organon-core' }, + advised: { repo: 'AdvisedOrganons', directory: 'AdvisedOrganons', package: '@shendeguize/advised-organons' }, + 'agent-organon': { repo: 'AgentOrganon', directory: '.', package: '@shendeguize/agent-organon' }, +}); +export const AGENTS = ['codex', 'claude', 'cursor', 'copilot', 'gemini', 'opencode']; +export const PLATFORMS = ['linux', 'darwin', 'win32']; +export const sha256 = data => crypto.createHash('sha256').update(data).digest('hex'); +export function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])); + return value; +} +export const digest = value => sha256(JSON.stringify(canonical(value))); +export const readJSON = file => JSON.parse(fs.readFileSync(file, 'utf8')); +export function writeJSON(file, data) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); +} +export function parseArgs(argv = process.argv.slice(2)) { + const options = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith('--')) { options._.push(token); continue; } + const key = token.slice(2); + if (key in options) throw new Error(`Duplicate option: ${token}`); + options[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true; + } + return options; +} +export function requireValue(options, key) { + if (typeof options[key] !== 'string' || !options[key]) throw new Error(`Required: --${key} VALUE`); + return options[key]; +} +export function confined(root, relative) { + if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.includes('\\')) throw new Error('Expected relative artifact path'); + const target = path.resolve(root, relative); + if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw new Error('Artifact escapes its root'); + let cursor = target; + while (cursor !== path.resolve(root)) { + if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error(`Symlink artifact path: ${relative}`); + cursor = path.dirname(cursor); + } + return target; +} +export function git(repo, ...args) { return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } +export function gh(args, body, env = process.env) { + return JSON.parse(execFileSync('gh', ['api', ...args, ...(body ? ['--input', '-'] : [])], { + input: body ? JSON.stringify(body) : undefined, env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], + }) || 'null'); +} +export function main(fn) { Promise.resolve().then(fn).catch(error => { console.error(error.message); process.exitCode = 1; }); } diff --git a/scripts/release/manifest.mjs b/scripts/release/manifest.mjs new file mode 100644 index 0000000..48fcdf9 --- /dev/null +++ b/scripts/release/manifest.mjs @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, AGENTS, PLATFORMS, sha256, digest, readJSON, writeJSON, confined, git, parseArgs, requireValue, main } from './lib.mjs'; + +import { checkPackage } from '../package/check.mjs'; +import { collectEvidenceClosure, validateApprovedAgentReport, validateTrustedEvidence } from './evidence.mjs'; + +const HEX = /^[a-f0-9]{64}$/; +const COMMIT = /^[a-f0-9]{40}$/; +export const sourceIdentity = manifest => digest({ version: manifest.version, sources: manifest.sources }); +export function assertManifest(manifest, { publish = false } = {}) { + if (manifest.schema_version !== 1 || !/^1\.0\.0(?:-rc\.[1-9]\d*)?$/.test(manifest.version)) throw new Error('Invalid release manifest/version'); + const rc = manifest.version.includes('-rc.'); + if (manifest.channel !== (rc ? 'rc' : 'stable')) throw new Error('Release channel does not match version'); + if (Object.keys(manifest.sources || {}).sort().join() !== Object.keys(PRODUCTS).sort().join()) throw new Error('All three source identities are required'); + for (const [product, definition] of Object.entries(PRODUCTS)) { + const source = manifest.sources[product]; + if (source.repository !== `shendeguize/${definition.repo}` || !COMMIT.test(source.commit)) throw new Error(`Invalid source identity: ${product}`); + if (publish && source.dirty) throw new Error(`Uncommitted source cannot be published: ${product}`); + } + if (manifest.source_digest !== sourceIdentity(manifest)) throw new Error('Source digest mismatch'); + if (publish && manifest.state !== 'validated') throw new Error('Candidate has not completed all validation gates'); + if (!rc && (!manifest.approved_rc || !/^1\.0\.0-rc\.[1-9]\d*$/.test(manifest.approved_rc.version) || !HEX.test(manifest.approved_rc.manifest_sha256))) throw new Error('Stable publication needs an exact approved RC manifest'); + return manifest; +} +export function assertArtifact(root, artifact) { + if (!artifact || !HEX.test(artifact.sha256)) throw new Error('Missing artifact checksum'); + const file = confined(root, artifact.file); + if (!fs.statSync(file).isFile() || sha256(fs.readFileSync(file)) !== artifact.sha256) throw new Error(`Artifact checksum mismatch: ${artifact.file}`); + return file; +} +export function assertGithubReport(report, product) { + const repository = PRODUCTS[product] && `shendeguize/${PRODUCTS[product].repo}`; + if (!repository || report?.status !== 'passed' || report.repository !== repository || report.security?.status !== 'passed' || report.security.repository !== repository) throw new Error(`Missing, failed or unrelated GitHub governance/security inspection: ${product}`); + return report; +} +export function validateEvidence(manifest, root, options = {}) { + assertManifest(manifest); + validateTrustedEvidence(manifest, root, options); + const reports = (manifest.evidence || []).map(item => { + const report = readJSON(assertArtifact(root, item)); + if (report.status !== 'passed' || report.source_digest !== manifest.source_digest || report.artifact_digest !== digest(manifest.artifacts)) throw new Error(`Unsuccessful or unrelated evidence: ${item.file}`); + if (report.gate === 'agents') validateApprovedAgentReport(root, report); + if (report.gate === 'github') assertGithubReport(report, report.product); + return report; + }); + for (const gate of ['content', 'site', 'package', 'github']) { + for (const product of Object.keys(PRODUCTS)) { + if (!reports.some(r => r.gate === gate && r.product === product)) throw new Error(`Missing ${gate} evidence: ${product}`); + } + } + for (const platform of PLATFORMS) { + if (!reports.some(r => r.gate === 'install' && r.platform === platform && r.lifecycle === 'complete')) throw new Error(`Missing installer lifecycle: ${platform}`); + for (const agent of AGENTS) { + const report = reports.find(r => r.gate === 'agents' && r.agent === agent && r.platform === platform && r.matrix === 'smoke'); + if (!report || !report.actual_call || !report.independent_reviewer || !report.raw_response || !report.tool_version || !report.model) throw new Error(`Missing actual agent smoke: ${agent}/${platform}`); + assertArtifact(root, report.raw_response); + } + } + for (const agent of AGENTS) { + const report = reports.find(r => r.gate === 'agents' && r.agent === agent && r.platform === 'linux' && r.matrix === 'full'); + if (!report || !report.actual_call || !report.independent_reviewer || !report.raw_response || !report.all_non_lean_skills || !report.independent_assessment) throw new Error(`Missing complete actual methods: ${agent}`); + assertArtifact(root, report.raw_response); + assertArtifact(root, report.independent_assessment); + } + validateReleasePackages(manifest, root); + collectEvidenceClosure(manifest, root); + return reports; +} +export function validateReleasePackages(manifest, root) { + assertManifest(manifest); + for (const [product] of Object.entries(PRODUCTS)) { + const checked = checkPackage(assertArtifact(root, manifest.artifacts?.[product])); + if (checked.product !== product || checked.version !== manifest.version) throw new Error('Package product/version does not match release: ' + product); + for (const [sourceProduct, source] of Object.entries(manifest.sources)) { + if (checked.snapshots[PRODUCTS[sourceProduct].repo] !== source.commit) throw new Error('Package source snapshots differ from release: ' + product + '/' + sourceProduct); + } + } + return true; +} +export function assertDispatch(manifest, environment, product = 'agent-organon') { + assertManifest(manifest, { publish: true }); + if (environment.RELEASE_VERSION && environment.RELEASE_VERSION !== manifest.version) throw new Error('Dispatch version differs from approved manifest'); + if (environment.GITHUB_ACTIONS !== 'true' || environment.GITHUB_EVENT_NAME !== 'workflow_dispatch') throw new Error('Publication runs only through explicitly dispatched GitHub Actions'); + if (!PRODUCTS[product] || environment.GITHUB_REPOSITORY !== `shendeguize/${PRODUCTS[product].repo}`) throw new Error('Wrong publication repository'); + if (environment.RELEASE_MANIFEST_SHA256 !== digest(manifest)) throw new Error('Approved manifest identity mismatch'); + if (environment.GITHUB_SHA !== manifest.sources[product].commit) throw new Error('Workflow commit does not match release source'); + if (environment.GITHUB_REF !== 'refs/heads/release/1.0.0') throw new Error('Publication must run from release/1.0.0'); + if (manifest.channel === 'stable' && environment.APPROVED_RC_MANIFEST_SHA256 !== manifest.approved_rc.manifest_sha256) throw new Error('Stable RC approval identity mismatch'); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(() => { + const options = parseArgs(); + const mode = options._[0] || 'check'; + if (mode === 'prepare') { + const workspace = path.resolve(options.workspace || '.'); + const version = requireValue(options, 'version'); + const sources = Object.fromEntries(Object.entries(PRODUCTS).map(([product, spec]) => { + const repo = path.join(workspace, spec.directory); + const dirty = Boolean(git(repo, 'status', '--porcelain=v1', '--untracked-files=normal')); + if (dirty && !options['allow-dirty']) throw new Error(`Source has uncommitted work: ${product}`); + return [product, { repository: `shendeguize/${spec.repo}`, commit: git(repo, 'rev-parse', 'HEAD'), dirty }]; + })); + const manifest = { schema_version: 1, version, channel: version.includes('-rc.') ? 'rc' : 'stable', state: 'prepared', sources, artifacts: {}, evidence: [] }; + if (options['approved-rc']) manifest.approved_rc = readJSON(options['approved-rc']); + manifest.source_digest = sourceIdentity(manifest); + assertManifest(manifest); + writeJSON(requireValue(options, 'out'), manifest); + console.log(JSON.stringify({ status: 'prepared', source_digest: manifest.source_digest })); + } else if (mode === 'check' || mode === 'seal') { + const file = path.resolve(requireValue(options, 'manifest')); + const manifest = readJSON(file); + validateEvidence(manifest, path.dirname(file), { sealing: mode === 'seal' }); + if (mode === 'seal') { manifest.state = 'validated'; assertManifest(manifest, { publish: true }); writeJSON(requireValue(options, 'out'), manifest); } + console.log(JSON.stringify({ status: 'passed', manifest_sha256: digest(manifest), source_digest: manifest.source_digest })); + } else throw new Error(`Unknown manifest command: ${mode}`); +}); diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs new file mode 100644 index 0000000..f8427d7 --- /dev/null +++ b/scripts/release/publish.mjs @@ -0,0 +1,172 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, sha256, digest, readJSON, writeJSON, confined, gh, parseArgs, requireValue, main } from './lib.mjs'; +import { assertManifest, assertArtifact, assertDispatch, validateEvidence, assertGithubReport } from './manifest.mjs'; +import { collectEvidenceClosure, fetchEvidenceClosure } from './evidence.mjs'; +import { validateStable } from './stable.mjs'; +import * as governance from './governance.mjs'; + +export const assetName = item => item.file.endsWith('.tgz') ? path.basename(item.file) : `${item.sha256}-${path.basename(item.file)}`; +const execute = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }); +const integrity = file => `sha512-${crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`; +export function releaseFiles(manifest, root) { + const items = collectEvidenceClosure(manifest, root), names = new Map(); + for (const item of items) { + const name = assetName(item); + if (names.has(name) && names.get(name) !== item.sha256) throw new Error(`Conflicting release asset name: ${name}`); + names.set(name, item.sha256); + } + return [...new Map(items.map(item => [assetName(item), item])).values()]; +} +export async function registryVersion(product, version) { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PRODUCTS[product].package)}/${version}`); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`npm registry lookup failed: HTTP ${response.status}`); + return response.json(); +} +async function publishNpm(manifest, product, file) { + const previous = await registryVersion(product, manifest.version); + const expected = integrity(file); + if (previous) { + if (previous.dist?.integrity !== expected) throw new Error(`Published version has different bytes: ${product}`); + return 'already-matching'; + } + const strategy = process.env.NPM_CHANNEL_STRATEGY; + if (!['direct', 'deferred'].includes(strategy)) throw new Error('Explicit approved NPM_CHANNEL_STRATEGY is required'); + const temporaryTag = strategy === 'direct' ? (manifest.channel === 'rc' ? 'rc' : 'latest') : `candidate-${manifest.version.replace(/\./g, '-')}`; + execute('npm', ['publish', file, '--access', 'public', '--provenance', '--tag', temporaryTag], { stdio: 'inherit' }); + const current = await registryVersion(product, manifest.version); + if (current?.dist?.integrity !== expected) throw new Error(`npm post-publication integrity mismatch: ${product}`); + return 'published'; +} +function getRelease(repository, tag) { + try { return gh([`repos/${repository}/releases/tags/${tag}`]); } + catch (error) { if (String(error.stderr || error.message).includes('404')) return null; throw error; } +} +function assertTag(repository, tag, commit) { + let object = gh([`repos/${repository}/git/ref/tags/${tag}`]).object; + for (let depth = 0; object.type === 'tag' && depth < 5; depth++) object = gh([`repos/${repository}/git/tags/${object.sha}`]).object; + if (object.type !== 'commit' || object.sha !== commit) throw new Error(`Release tag has different source: ${repository}/${tag}`); +} +function ensureGithub(manifest, product, root, { coordinator = false } = {}) { + const repository = manifest.sources[product].repository; + const tag = `v${manifest.version}`; + let release = getRelease(repository, tag); + if (!release) { + release = gh([`repos/${repository}/releases`, '--method', 'POST'], { tag_name: tag, target_commitish: manifest.sources[product].commit, + name: `${PRODUCTS[product].repo} ${manifest.version}`, draft: true, prerelease: manifest.channel === 'rc', make_latest: 'false', + body: `Product ${manifest.version}. Manifest identity: ${digest(manifest)}. Philosophy and product versions are separate. Install and validation details are in the attached manifest.`, }); + } + if (release.draft && release.target_commitish !== manifest.sources[product].commit) throw new Error('Existing draft targets a different source commit'); + if (!release.draft) assertTag(repository, tag, manifest.sources[product].commit); + else { + try { gh([`repos/${repository}/git/ref/tags/${tag}`]); assertTag(repository, tag, manifest.sources[product].commit); } + catch (error) { if (!String(error.stderr || error.message).includes('404')) throw error; } + } + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-publish-')); + try { + const manifestFile = path.join(temporary, 'release-manifest.json'); writeJSON(manifestFile, manifest); + const items = coordinator ? releaseFiles(manifest, root) : [manifest.artifacts[product]]; + const upload = [{ file: manifestFile, name: 'release-manifest.json' }, ...items.map(item => ({ file: assertArtifact(root, item), name: assetName(item) }))]; + for (const item of upload) { + const existing = release.assets.find(asset => asset.name === item.name); + if (existing) { + const bytes = execFileSync('gh', ['api', `repos/${repository}/releases/assets/${existing.id}`, '-H', 'Accept: application/octet-stream'], { maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + if (sha256(bytes) !== sha256(fs.readFileSync(item.file))) throw new Error(`Existing release asset differs: ${item.name}`); + } else { + if (!release.draft) throw new Error(`Published release is incomplete; use a new RC: ${item.name}`); + const copied = path.join(temporary, item.name); if (copied !== item.file) fs.copyFileSync(item.file, copied); + execute('gh', ['release', 'upload', tag, copied, '--repo', repository]); + } + } + if (release.draft) gh([`repos/${repository}/releases/${release.id}`, '--method', 'PATCH'], { draft: false, prerelease: manifest.channel === 'rc', make_latest: 'false' }); + assertTag(repository, tag, manifest.sources[product].commit); + } finally { fs.rmSync(temporary, { recursive: true, force: true }); } +} +export async function recheckReleaseGovernance(manifest, inspect = governance.inspectReleaseGovernance) { + assertManifest(manifest); + const reports = []; + for (const product of Object.keys(PRODUCTS)) { + const report = await inspect(manifest.sources[product].repository); + assertGithubReport(report, product); + reports.push({ ...report, product }); + } + return reports; +} +export async function withReleaseGovernance(manifest, operation, inspect = governance.inspectReleaseGovernance) { + const reports = await recheckReleaseGovernance(manifest, inspect); + return operation(reports); +} +export async function verifyPublished(manifest, root, products = Object.keys(PRODUCTS), { inspect = governance.inspectReleaseGovernance } = {}) { + return withReleaseGovernance(manifest, async () => { + for (const product of products) { + const current = await registryVersion(product, manifest.version); + if (current?.dist?.integrity !== integrity(assertArtifact(root, manifest.artifacts[product]))) throw new Error(`Not all npm products are published with matching bytes: ${product}`); + const repository = manifest.sources[product].repository; + const release = getRelease(repository, `v${manifest.version}`); + if (!release || release.draft || release.prerelease !== (manifest.channel === 'rc')) throw new Error(`GitHub product not published: ${product}`); + assertTag(repository, `v${manifest.version}`, manifest.sources[product].commit); + const checks = [{ name: 'release-manifest.json', expected: sha256(`${JSON.stringify(manifest, null, 2)}\n`) }, + ...((product === 'agent-organon' ? releaseFiles(manifest, root) : [manifest.artifacts[product]])).map(item => ({ name: assetName(item), expected: item.sha256 }))]; + for (const item of checks) { + const asset = release.assets.find(value => value.name === item.name); + if (!asset) throw new Error(`GitHub artifact missing: ${product}/${item.name}`); + const bytes = execFileSync('gh', ['api', `repos/${repository}/releases/assets/${asset.id}`, '-H', 'Accept: application/octet-stream'], { maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + if (sha256(bytes) !== item.expected) throw new Error(`Downloaded GitHub bytes differ: ${product}/${item.name}`); + } + if (!current.dist?.tarball?.startsWith('https://registry.npmjs.org/')) throw new Error('Unexpected npm tarball origin'); + const download = await fetch(current.dist.tarball); + if (!download.ok) throw new Error(`npm tarball download failed: ${product}`); + if (sha256(Buffer.from(await download.arrayBuffer())) !== manifest.artifacts[product].sha256) throw new Error(`Downloaded npm bytes differ: ${product}`); + } + }, inspect); +} +async function fetchAsset(version, name, expected, destination) { + const response = await fetch(`https://github.com/shendeguize/AgentOrganon/releases/download/v${version}/${encodeURIComponent(name)}`); + if (!response.ok) throw new Error(`Validated release bundle unavailable: ${name}, HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 64 * 1024 * 1024 || (expected && sha256(bytes) !== expected)) throw new Error(`Invalid bundle asset: ${name}`); + fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.writeFileSync(destination, bytes); +} +export async function fetchBundle(version, expectedManifest, root) { + if (!/^1\.0\.0(?:-rc\.[1-9]\d*)?$/.test(version) || !/^[a-f0-9]{64}$/.test(expectedManifest)) throw new Error('Invalid bundle identity'); + const file = path.join(root, 'release-manifest.json'); + await fetchAsset(version, 'release-manifest.json', null, file); + const manifest = readJSON(file); assertManifest(manifest, { publish: true }); + if (manifest.version !== version || digest(manifest) !== expectedManifest) throw new Error('Downloaded manifest is not the approved object'); + const fetchItem = item => fetchAsset(version, assetName(item), item.sha256, confined(root, item.file)); + await fetchEvidenceClosure(manifest, root, fetchItem); + validateEvidence(manifest, root); return manifest; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const args = parseArgs(); const operation = args._[0]; + if (operation === 'fetch') { const manifest = await fetchBundle(requireValue(args, 'version'), requireValue(args, 'manifest-sha256'), path.resolve(requireValue(args, 'out'))); console.log(JSON.stringify({ status: 'passed', manifest_sha256: digest(manifest) })); return; } + const file = path.resolve(requireValue(args, 'manifest')); const root = path.dirname(file); const manifest = readJSON(file); + validateEvidence(manifest, root); + if (operation === 'verify') { await verifyPublished(manifest, root); console.log(JSON.stringify({ status: 'passed' })); return; } + const product = requireValue(args, 'product'); assertDispatch(manifest, process.env, product); + if (manifest.channel === 'stable') await validateStable(manifest, root); + await withReleaseGovernance(manifest, async () => { + if (operation === 'bootstrap-bundle') { + if (product !== 'agent-organon') throw new Error('Only the coordinator publishes the validated bundle'); + ensureGithub(manifest, product, root, { coordinator: true }); + } else if (operation === 'publish') { + if (process.env.NPM_CHANNEL_STRATEGY === 'direct') await verifyPublished(manifest, root, Object.keys(PRODUCTS).slice(0, Object.keys(PRODUCTS).indexOf(product))); + await publishNpm(manifest, product, assertArtifact(root, manifest.artifacts[product])); + ensureGithub(manifest, product, root, { coordinator: product === 'agent-organon' }); + } else if (operation === 'promote') { + await verifyPublished(manifest, root); + if (process.env.NPM_CHANNEL_STRATEGY === 'deferred') { + if (!process.env.NODE_AUTH_TOKEN) throw new Error('Deferred npm channel promotion requires a separately authorized tag-management credential; OIDC does not support dist-tag'); + execute('npm', ['dist-tag', 'add', `${PRODUCTS[product].package}@${manifest.version}`, manifest.channel === 'rc' ? 'rc' : 'latest'], { stdio: 'inherit' }); + } else if (process.env.NPM_CHANNEL_STRATEGY !== 'direct') throw new Error('Explicit approved NPM_CHANNEL_STRATEGY is required'); + const release = getRelease(manifest.sources[product].repository, `v${manifest.version}`); + if (manifest.channel === 'stable') gh([`repos/${manifest.sources[product].repository}/releases/${release.id}`, '--method', 'PATCH'], { make_latest: 'true' }); + } else throw new Error('Expected fetch, bootstrap-bundle, publish, promote or verify'); + }); + console.log(JSON.stringify({ status: 'passed', operation, product, version: manifest.version, manifest_sha256: digest(manifest) })); +}); diff --git a/scripts/release/security.mjs b/scripts/release/security.mjs new file mode 100644 index 0000000..bef0b96 --- /dev/null +++ b/scripts/release/security.mjs @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gh, parseArgs, requireValue, main, writeJSON } from './lib.mjs'; +import { allowedRepository } from './governance.mjs'; + +export const environmentPlan = repository => [ + { name: 'npm-rc', branches: ['release/1.0.0'], review: false }, + { name: 'npm-stable', branches: ['release/1.0.0'], review: true }, + { name: 'github-pages', branches: ['main', 'release/1.0.0'], review: false }, + ...(repository.endsWith('/AgentOrganon') ? [ + { name: 'agent-validation', branches: ['main', 'release/1.0.0'], review: true }, + { name: 'release-validation', branches: ['release/1.0.0'], review: true }, + ] : []), +]; +export function inspectSecurity(repository, api = gh) { + allowedRepository(repository); + const actions = api([`repos/${repository}/actions/permissions`]); + const workflow = api([`repos/${repository}/actions/permissions/workflow`]); + const missing = []; + if (!actions.enabled || actions.sha_pinning_required !== true) missing.push('full Action revision pinning'); + if (workflow.default_workflow_permissions !== 'read' || workflow.can_approve_pull_request_reviews !== false) missing.push('read-only default token without PR approval'); + const environments = []; + for (const wanted of environmentPlan(repository)) { + let actual; + try { actual = api([`repos/${repository}/environments/${wanted.name}`]); } + catch (error) { if (!String(error.stderr || error.message).includes('404')) throw error; missing.push(`environment ${wanted.name}`); continue; } + if (actual.deployment_branch_policy?.protected_branches !== false || actual.deployment_branch_policy?.custom_branch_policies !== true) missing.push(`${wanted.name}: exact deployment branches`); + const branches = api([`repos/${repository}/environments/${wanted.name}/deployment-branch-policies`]).branch_policies; + if (branches.some(item => item.type !== 'branch') || branches.map(item => item.name).sort().join() !== [...wanted.branches].sort().join()) missing.push(`${wanted.name}: deployment branch list`); + const reviewer = actual.protection_rules.find(item => item.type === 'required_reviewers'); + if (wanted.review && (!reviewer || reviewer.prevent_self_review !== false || reviewer.reviewers.length !== 1 || reviewer.reviewers[0].reviewer.login !== 'shendeguize' || actual.can_admins_bypass !== false)) missing.push(`${wanted.name}: owner approval without administrator bypass`); + environments.push({ name: wanted.name, can_admins_bypass: actual.can_admins_bypass, branches: branches.map(item => item.name), owner_approval: wanted.review }); + } + return { status: missing.length ? 'failed' : 'passed', repository, missing, actions, workflow, environments }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(() => { + const args = parseArgs(), repository = allowedRepository(requireValue(args, 'repository')); + const operation = args._[0] || 'check'; + if (operation === 'plan') { console.log(JSON.stringify({ repository, environments: environmentPlan(repository), actions: { enabled: true, sha_pinning_required: true }, workflow: { default_workflow_permissions: 'read', can_approve_pull_request_reviews: false } }, null, 2)); return; } + if (operation === 'apply') { + const current = gh([`repos/${repository}/actions/permissions`]); + gh([`repos/${repository}/actions/permissions`, '--method', 'PUT'], { enabled: true, allowed_actions: current.allowed_actions, sha_pinning_required: true }); + gh([`repos/${repository}/actions/permissions/workflow`, '--method', 'PUT'], { default_workflow_permissions: 'read', can_approve_pull_request_reviews: false }); + const owner = gh(['users/shendeguize']); + for (const environment of environmentPlan(repository)) { + gh([`repos/${repository}/environments/${environment.name}`, '--method', 'PUT'], { wait_timer: 0, prevent_self_review: false, + reviewers: environment.review ? [{ type: 'User', id: owner.id }] : [], deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } }); + const existing = gh([`repos/${repository}/environments/${environment.name}/deployment-branch-policies`]).branch_policies; + for (const item of existing) if (item.type !== 'branch' || !environment.branches.includes(item.name)) gh([`repos/${repository}/environments/${environment.name}/deployment-branch-policies/${item.id}`, '--method', 'DELETE']); + for (const name of environment.branches) if (!existing.some(item => item.type === 'branch' && item.name === name)) gh([`repos/${repository}/environments/${environment.name}/deployment-branch-policies`, '--method', 'POST'], { name, type: 'branch' }); + } + // The documented REST update does not expose administrator bypass. Inspect it + // and require the actual GitHub UI setting rather than inventing an API field. + } else if (operation !== 'check') throw new Error('Expected plan, apply or check'); + const result = inspectSecurity(repository); + if (args.out) writeJSON(args.out, result); + console.log(JSON.stringify(result, null, 2)); if (result.status !== 'passed') process.exitCode = 1; +}); diff --git a/scripts/release/site-data.mjs b/scripts/release/site-data.mjs new file mode 100644 index 0000000..2f202bc --- /dev/null +++ b/scripts/release/site-data.mjs @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, digest, sha256, readJSON, writeJSON, parseArgs, requireValue, main } from './lib.mjs'; +import { assertManifest } from './manifest.mjs'; +import { validateHistory, observe, renderSVG } from './stars.mjs'; +import { fetchBundle, verifyPublished } from './publish.mjs'; + +const MUTABLE = new Set(['assets/stars.json', 'assets/stars.svg']); +export function productFor(repository) { + const product = Object.keys(PRODUCTS).find(key => repository === `shendeguize/${PRODUCTS[key].repo}`); + if (!product) throw new Error('Unexpected site repository'); + return product; +} +export function assertSiteRunner(env, operation) { + productFor(env.GITHUB_REPOSITORY); + if (!['stars', 'pages'].includes(operation) || env.GITHUB_ACTIONS !== 'true' || env.RUNNER_ENVIRONMENT !== 'github-hosted' || !env.RUNNER_TEMP || !env.GITHUB_TOKEN || !['refs/heads/main', 'refs/heads/release/1.0.0'].includes(env.GITHUB_REF) || !(operation === 'stars' ? ['schedule', 'workflow_dispatch'] : ['workflow_dispatch']).includes(env.GITHUB_EVENT_NAME)) throw new Error('Site writes require a reviewed GitHub-hosted workflow'); +} +export function inventory(root) { + if (fs.lstatSync(root).isSymbolicLink() || !fs.statSync(root).isDirectory()) throw new Error('Invalid public site directory'); + const files = {}; + function visit(directory, prefix = '') { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) { + const relative = `${prefix}${entry.name}`; + if (entry.isSymbolicLink()) throw new Error('Site tree contains a symlink'); + if (entry.isDirectory()) visit(path.join(directory, entry.name), `${relative}/`); + else if (entry.isFile()) { if (!MUTABLE.has(relative)) files[relative] = sha256(fs.readFileSync(path.join(directory, entry.name))); } + else throw new Error('Site tree contains a special file'); + } + } + visit(root); + if (!files['index.html'] || !files['zh/index.html']) throw new Error('Missing bilingual site entrypoints'); + return files; +} +export function validateState(root, repository) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name === '.git' && entry.isDirectory()) continue; + if (!['stars.json', 'stars.svg', 'public', 'release-manifest.json', 'recommended-release.json'].includes(entry.name) || entry.isSymbolicLink() || (entry.name === 'public' ? !entry.isDirectory() : !entry.isFile())) throw new Error('Unexpected or unsafe site-data entry'); + } + if (fs.existsSync(path.join(root, 'stars.json'))) validateHistory(readJSON(path.join(root, 'stars.json')), repository); + const recommendation = path.join(root, 'recommended-release.json'); + if (!fs.existsSync(recommendation)) { + if (fs.existsSync(path.join(root, 'public')) || fs.existsSync(path.join(root, 'release-manifest.json'))) throw new Error('Orphan public site without a release receipt'); + return null; + } + const saved = readJSON(recommendation), manifest = readJSON(path.join(root, 'release-manifest.json')); + assertManifest(manifest, { publish: true }); + const product = productFor(repository); + if (saved.schema_version !== 1 || saved.repository !== repository || saved.version !== manifest.version || saved.manifest_sha256 !== digest(manifest) || saved.source_commit !== manifest.sources[product].commit || saved.core_commit !== manifest.sources.core.commit || saved.verification !== 'all-three-npm-and-github' || !saved.verified_at || Number.isNaN(Date.parse(saved.verified_at)) || digest(saved.site_files) !== digest(inventory(path.join(root, 'public')))) throw new Error('Stored public site identity differs from verified release'); + return saved; +} +export async function sampleStars(root, repository, fetcher = fetch, now = new Date(), token) { + const previous = fs.existsSync(path.join(root, 'stars.json')) ? validateHistory(readJSON(path.join(root, 'stars.json')), repository) : { schema_version: 1, repository, observations: [] }; + const saved = validateState(root, repository); + const response = await fetcher(`https://api.github.com/repos/${repository}`, { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(token ? { Authorization: `Bearer ${token}` } : {}) } }); + if (!response.ok) throw new Error(`Star observation failed: HTTP ${response.status}; state unchanged`); + const history = observe(previous, (await response.json()).stargazers_count, now); + writeJSON(path.join(root, 'stars.json'), history); + fs.writeFileSync(path.join(root, 'stars.svg'), renderSVG(history)); + if (saved) copyStars(root); + return Boolean(saved); +} +function copyStars(root) { + const history = readJSON(path.join(root, 'stars.json')); + validateHistory(history, history.repository); + const assets = path.join(root, 'public/assets'); fs.mkdirSync(assets, { recursive: true }); + writeJSON(path.join(assets, 'stars.json'), history); + fs.writeFileSync(path.join(assets, 'stars.svg'), renderSVG(history)); +} +export function installPublic(root, repository, manifest, built, verifiedAt) { + assertManifest(manifest, { publish: true }); + const previous = validateState(root, repository), product = productFor(repository); + const rank = version => version === '1.0.0' ? Infinity : Number(version.split('-rc.')[1]); + if (previous && (rank(previous.version) > rank(manifest.version) || (previous.version === manifest.version && previous.manifest_sha256 !== digest(manifest)))) throw new Error('Cannot replace a recommendation with older or different same-version content'); + const files = inventory(built); + if (!verifiedAt || Number.isNaN(Date.parse(verifiedAt))) throw new Error('Missing completed publication verification time'); + fs.rmSync(path.join(root, 'public'), { recursive: true, force: true }); + fs.cpSync(built, path.join(root, 'public'), { recursive: true }); + writeJSON(path.join(root, 'release-manifest.json'), manifest); + writeJSON(path.join(root, 'recommended-release.json'), { schema_version: 1, repository, version: manifest.version, manifest_sha256: digest(manifest), source_commit: manifest.sources[product].commit, core_commit: manifest.sources.core.commit, verification: 'all-three-npm-and-github', verified_at: verifiedAt, site_files: files }); + if (fs.existsSync(path.join(root, 'stars.json'))) copyStars(root); + validateState(root, repository); +} +export function buildIdentity(repo, core, manifest, product) { + const site = readJSON(path.join(repo, 'site/site.json')); + const theme = readJSON(path.join(core, 'tools/site/package.json')); + if (site.repository !== PRODUCTS[product].repo || site.product !== product || site.version !== manifest.version || site.themeVersion !== theme.version || theme.version !== manifest.version) throw new Error('Site/product/theme version differs from fixed release'); +} +function cleanEnv(scratch) { + const env = Object.fromEntries(['PATH', 'SystemRoot', 'WINDIR', 'LANG', 'LC_ALL'].filter(key => process.env[key]).map(key => [key, process.env[key]])); + const home = path.join(scratch, 'home'), temp = path.join(scratch, 'tmp'); fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(temp, { recursive: true }); + return { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: path.join(home, '.config'), TMPDIR: temp, TMP: temp, TEMP: temp, npm_config_cache: path.join(scratch, 'npm-cache'), CI: 'true' }; +} +const execute = (command, args, options = {}) => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024, ...options }).trim(); +export function checkoutState(root, repository, env, executor = execute) { + fs.mkdirSync(root, { recursive: true }); + const git = (...args) => executor('git', ['-C', root, ...args], { env }); + git('init'); git('remote', 'add', 'origin', `https://github.com/${repository}.git`); + let exists = true; + try { git('ls-remote', '--exit-code', '--heads', 'origin', 'refs/heads/site-data'); } + catch (error) { if (error.status !== 2) throw error; exists = false; } + if (exists) { git('fetch', '--depth=1', 'origin', 'refs/heads/site-data'); git('checkout', '-B', 'site-data', 'FETCH_HEAD'); } + else git('checkout', '--orphan', 'site-data'); + return git; +} +function checkoutSource(scratch, source, env) { + const directory = path.join(scratch, source.repository.split('/')[1]); + fs.mkdirSync(directory, { recursive: true }); + const git = (...args) => execute('git', ['-C', directory, ...args], { env }); + git('init'); git('remote', 'add', 'origin', `https://github.com/${source.repository}.git`); git('fetch', '--depth=1', 'origin', source.commit); git('checkout', '--detach', 'FETCH_HEAD'); + if (git('rev-parse', 'HEAD') !== source.commit) throw new Error('Source checkout identity mismatch'); + return directory; +} +export async function verifyForSite(manifest, bundle, verifier = verifyPublished) { + await verifier(manifest, bundle); + return new Date().toISOString(); +} +async function buildReleased(scratch, repository, version, hash, env) { + const bundle = path.join(scratch, 'bundle'); + const manifest = await fetchBundle(version, hash, bundle); + const verifiedAt = await verifyForSite(manifest, bundle), product = productFor(repository); + const core = checkoutSource(scratch, manifest.sources.core, env); + const repo = product === 'core' ? core : checkoutSource(scratch, manifest.sources[product], env); + if (product === 'agent-organon') { + for (const key of ['core', 'advised']) { + const relative = PRODUCTS[key].directory; + const link = execute('git', ['-C', repo, 'ls-tree', 'HEAD', relative], { env }); + if (!link.startsWith(`160000 commit ${manifest.sources[key].commit}\t`)) throw new Error('Released workspace gitlink mismatch'); + } + fs.rmSync(path.join(repo, 'OrganonCore'), { recursive: true, force: true }); fs.cpSync(core, path.join(repo, 'OrganonCore'), { recursive: true }); + const advised = checkoutSource(scratch, manifest.sources.advised, env); + fs.rmSync(path.join(repo, 'AdvisedOrganons'), { recursive: true, force: true }); fs.cpSync(advised, path.join(repo, 'AdvisedOrganons'), { recursive: true }); + } + if (product === 'advised') { + const tooling = readJSON(path.join(repo, 'release/tooling.json')); + if (tooling.core?.repository !== manifest.sources.core.repository || tooling.core?.commit !== manifest.sources.core.commit) throw new Error('Released domain theme pin differs from manifest'); + } + buildIdentity(repo, core, manifest, product); + execute('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: path.join(core, 'tools/site'), env }); + for (const operation of ['build', 'check']) execute(process.execPath, [path.join(core, `tools/site/${operation}.mjs`), '--repo', repo], { env }); + return { manifest, built: path.join(repo, 'dist/site'), verifiedAt }; +} +export async function runSiteData(args, env = process.env) { + const operation = args._[0]; assertSiteRunner(env, operation); + const scratch = fs.mkdtempSync(path.join(env.RUNNER_TEMP, 'organon-site-')); + const localEnv = cleanEnv(scratch), state = path.join(scratch, 'state'); + const git = checkoutState(state, env.GITHUB_REPOSITORY, localEnv); + let deploy; + if (operation === 'stars') deploy = await sampleStars(state, env.GITHUB_REPOSITORY, fetch, new Date(), env.GITHUB_TOKEN); + else { + const release = await buildReleased(scratch, env.GITHUB_REPOSITORY, requireValue(args, 'version'), requireValue(args, 'manifest-sha256'), localEnv); + installPublic(state, env.GITHUB_REPOSITORY, release.manifest, release.built, release.verifiedAt); deploy = true; + } + // Git configuration is process-local; the token never enters a URL, argv or a persisted config. + const pushEnv = { ...localEnv, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${env.GITHUB_TOKEN}`).toString('base64')}` }; + git('config', 'user.name', 'github-actions[bot]'); git('config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'); + git('add', '--all'); + if (git('status', '--porcelain')) { git('commit', '-m', operation === 'stars' ? 'Record observed GitHub stars' : 'Publish verified release site'); execute('git', ['-C', state, 'push', 'origin', 'HEAD:refs/heads/site-data'], { env: pushEnv }); } + if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `deploy=${deploy}\npublic_dir=${path.join(state, 'public')}\n`); + return { status: 'passed', operation, deploy, repository: env.GITHUB_REPOSITORY }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => console.log(JSON.stringify(await runSiteData(parseArgs())))); diff --git a/scripts/release/site.mjs b/scripts/release/site.mjs new file mode 100644 index 0000000..0032ba5 --- /dev/null +++ b/scripts/release/site.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const core = process.env.ORGANON_CORE_ROOT || path.join(root, 'OrganonCore'); +const mode = process.argv[2]; +if (!['build', 'check'].includes(mode)) throw new Error('Expected build or check'); +const script = path.join(core, 'tools/site', `${mode}.mjs`); +if (!fs.existsSync(script)) throw new Error('Missing fixed Core site tooling; initialize OrganonCore or set ORGANON_CORE_ROOT'); +const result = spawnSync(process.execPath, [script, '--repo', root, ...process.argv.slice(3)], { stdio: 'inherit' }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/release/stable.mjs b/scripts/release/stable.mjs new file mode 100644 index 0000000..d677c3d --- /dev/null +++ b/scripts/release/stable.mjs @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { PRODUCTS, digest, parseArgs, requireValue, readJSON, main } from './lib.mjs'; +import { assertManifest } from './manifest.mjs'; + +const SHA = /^[a-f0-9]{40}$/; +const HASH = /^[a-f0-9]{64}$/; +const REPOSITORIES = new Set(Object.values(PRODUCTS).map(product => `shendeguize/${product.repo}`)); +const INTERNAL_PACKAGES = new Set([...Object.values(PRODUCTS).map(product => product.package), '@shendeguize/organon-site-tools']); +const gitBlobHash = bytes => crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); +const utf8 = bytes => { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (text.includes('\0')) throw new Error('Binary content cannot receive a release text transformation'); + return text; +}; +function insist(condition, message) { if (!condition) throw new Error(message); } +function checkedRepository(repository) { insist(REPOSITORIES.has(repository), 'Unexpected source repository'); return repository; } +function checkedSHA(sha) { insist(SHA.test(sha), 'Expected an immutable Git object SHA'); return sha; } +async function responseBytes(response, limit) { + insist(response.ok, `Immutable release source unavailable: HTTP ${response.status}`); + const parts = []; let size = 0; + for await (const part of response.body) { + size += part.length; insist(size <= limit, 'Immutable release source exceeds read limit'); parts.push(part); + } + return Buffer.concat(parts); +} + +/** Read-only transport. The test backend supplies the same object-shaped methods. */ +export function githubBackend({ token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN } = {}) { + const request = async (repository, suffix) => { + const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`https://api.github.com/repos/${checkedRepository(repository)}/${suffix}`, { headers, signal: AbortSignal.timeout(30000) }); + return JSON.parse((await responseBytes(response, 32 * 1024 * 1024)).toString('utf8')); + }; + return { + async rcManifest(version) { + insist(/^1\.0\.0-rc\.[1-9]\d*$/.test(version), 'Expected an exact RC version'); + const response = await fetch(`https://github.com/shendeguize/AgentOrganon/releases/download/v${version}/release-manifest.json`, { signal: AbortSignal.timeout(30000) }); + return JSON.parse((await responseBytes(response, 4 * 1024 * 1024)).toString('utf8')); + }, + commit: (repository, sha) => request(repository, `git/commits/${checkedSHA(sha)}`), + tree: (repository, sha) => request(repository, `git/trees/${checkedSHA(sha)}?recursive=1`), + async blob(repository, sha) { + const response = await request(repository, `git/blobs/${checkedSHA(sha)}`); + insist(response.sha === sha && response.encoding === 'base64', 'GitHub returned an unrelated blob'); + const bytes = Buffer.from(response.content, 'base64'); + insist(response.size === bytes.length, 'GitHub blob size mismatch'); + return bytes; + }, + }; +} + +function treeEntries(tree, sha) { + insist(tree.sha === sha && tree.truncated === false && Array.isArray(tree.tree), 'Incomplete or unrelated Git tree'); + const result = new Map(); + for (const entry of tree.tree) { + insist(typeof entry.path === 'string' && entry.path && !entry.path.includes('\\') && !entry.path.split('/').some(part => !part || part === '.' || part === '..') && SHA.test(entry.sha), 'Invalid Git tree entry'); + insist(!result.has(entry.path), 'Duplicate Git tree entry'); + const types = { '040000': 'tree', '100644': 'blob', '100755': 'blob', '120000': 'blob', '160000': 'commit' }; + insist(types[entry.mode] === entry.type, 'Unsupported Git tree mode/type'); + result.set(entry.path, entry); + } + return result; +} +const protectedPath = file => /(?:^|\/)(?:PHILOSOPHY(?:\.lock)?\.json|PHILOSOPHY\.md|lean(?:\/|\.|$)|rationale(?:\/|\.|$)|philosophy(?:\/|\.|$))/i.test(file); +function replaceVersion(text, version) { + const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.replace(new RegExp(`(? { + if (!blobs.has(entry.sha)) { + const bytes = Buffer.from(await backend.blob(repository, entry.sha)); + insist(gitBlobHash(bytes) === entry.sha, 'Git blob does not match its immutable identity'); + blobs.set(entry.sha, bytes); + } + return blobs.get(entry.sha); + }; + const oldPackage = oldEntries.get('package.json'); const newPackage = newEntries.get('package.json'); + insist(oldPackage?.type === 'blob' && newPackage?.type === 'blob', 'Product package metadata is missing'); + const oldMetadata = JSON.parse(utf8(await read(oldPackage))); const newMetadata = JSON.parse(utf8(await read(newPackage))); + insist(oldMetadata.name === spec.package && newMetadata.name === spec.package && oldMetadata.version === rc.version && newMetadata.version === '1.0.0', `Product metadata does not implement the approved version transition: ${product}`); + for (const [file, oldEntry] of oldEntries) { + const newEntry = newEntries.get(file); + insist(oldEntry.type === newEntry.type && oldEntry.mode === newEntry.mode, `Stable transition changes a file type/mode: ${product}/${file}`); + if (oldEntry.type === 'tree') continue; + if (product === 'agent-organon' && ['OrganonCore', 'AdvisedOrganons'].includes(file)) { + const peer = file === 'OrganonCore' ? 'core' : 'advised'; + insist(oldEntry.type === 'commit' && oldEntry.sha === rc.sources[peer].commit && newEntry.sha === manifest.sources[peer].commit, `Peer gitlink does not match the frozen source identity: ${file}`); + if (oldEntry.sha !== newEntry.sha) changes.push({ product, path: file, before: oldEntry.sha, after: newEntry.sha, reason: 'peer-gitlink' }); + continue; + } + if (oldEntry.sha === newEntry.sha) continue; + insist(oldEntry.type === 'blob' && oldEntry.mode !== '120000', `Unapproved symbolic-link or gitlink change: ${product}/${file}`); + const reason = permittedBlob(file, utf8(await read(oldEntry)), utf8(await read(newEntry)), rc, manifest, product); + changes.push({ product, path: file, before: oldEntry.sha, after: newEntry.sha, reason }); + } + if (product === 'agent-organon') for (const peer of ['OrganonCore', 'AdvisedOrganons']) insist(oldEntries.has(peer), `Required peer gitlink is missing: ${peer}`); + repositories[product] = { repository, rc_commit: oldCommit, stable_commit: newCommit, rc_tree: oldObject.tree.sha, stable_tree: newObject.tree.sha }; + } + return { schema_version: 1, gate: 'stable-transition', status: 'passed', approved_rc_manifest_sha256: approvalDigest, stable_manifest_sha256: digest(manifest), source_digest: manifest.source_digest, repositories, changes, approval_binding: 'separate-configured-RC-digest', scope: 'Immutable source differences only; rebuilt packages, fresh validation gates and user approval remain separate requirements.' }; +} + +if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const args = parseArgs(); const file = path.resolve(requireValue(args, 'manifest')); + console.log(JSON.stringify(await validateStable(readJSON(file), path.dirname(file)), null, 2)); +}); diff --git a/scripts/release/stars.mjs b/scripts/release/stars.mjs new file mode 100644 index 0000000..64ea7d6 --- /dev/null +++ b/scripts/release/stars.mjs @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, readJSON, writeJSON, main, PRODUCTS } from './lib.mjs'; + +export function validateHistory(history, repository) { + if (history.schema_version !== 1 || history.repository !== repository || !Array.isArray(history.observations)) throw new Error('Invalid star history identity'); + let previous = ''; + for (const point of history.observations) { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(point.observed_at) || Number.isNaN(Date.parse(point.observed_at)) || point.observed_at <= previous || !Number.isSafeInteger(point.total) || point.total < 0) throw new Error('Invalid or unordered star observation'); + previous = point.observed_at; + } + return history; +} +export function observe(history, total, now = new Date()) { + validateHistory(history, history.repository); + if (!Number.isSafeInteger(total) || total < 0) throw new Error('Invalid GitHub star count'); + const timestamp = now.toISOString(); + const last = history.observations.at(-1); + if (last && timestamp <= last.observed_at) throw new Error('Observation must advance time'); + // At most one actual observation per UTC date; retries cannot rewrite history. + if (last?.observed_at.slice(0, 10) === timestamp.slice(0, 10)) return history; + return { ...history, observations: [...history.observations, { observed_at: timestamp, total }] }; +} +const escape = text => String(text).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +export function renderSVG(history) { + validateHistory(history, history.repository); + const points = history.observations; + const last = points.at(-1); + const firstTime = points.length ? Date.parse(points[0].observed_at) : 0; + const span = last ? Math.max(86400000, Date.parse(last.observed_at) - firstTime) : 86400000; + const ceiling = Math.max(1, ...points.map(p => p.total)); + const xy = p => [56 + (Date.parse(p.observed_at) - firstTime) / span * 700, 176 - p.total / ceiling * 105]; + const segments = []; + for (let i = 1; i < points.length; i++) { + if (Date.parse(points[i].observed_at.slice(0, 10)) - Date.parse(points[i - 1].observed_at.slice(0, 10)) === 86400000) { + const [a, b] = [xy(points[i - 1]), xy(points[i])]; + segments.push(``); + } + } + return `${escape(history.repository)} — observed GitHub starsDaily observed totals, including decreases. Missing dates are gaps. ${last ? `Last observed ${last.observed_at}: ${last.total}.` : 'No observations yet.'}GitHub stars · ${last ? last.total : 'not yet observed'}${escape(history.repository)}${last ? `Observed since ${points[0].observed_at.slice(0, 10)} · Updated ${last.observed_at}` : 'Collection starts with the first successful observation.'}Daily total / 每日总数 · gaps indicate missing observations / 缺样保留断点${segments.join('')}${points.map(p => { const [x,y]=xy(p); return `${p.observed_at}: ${p.total}`; }).join('')}\n`; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(async () => { + const args = parseArgs(); + const repository = args.repository; + if (!Object.values(PRODUCTS).some(p => `shendeguize/${p.repo}` === repository)) throw new Error('Expected one of the three Organon repositories'); + const file = path.resolve(args.history || 'site/public/assets/stars.json'); + let history = fs.existsSync(file) ? validateHistory(readJSON(file), repository) : { schema_version: 1, repository, observations: [] }; + if (!args.render) { + const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + const response = await fetch(`https://api.github.com/repos/${repository}`, { headers }); + if (!response.ok) throw new Error(`GitHub star observation failed: HTTP ${response.status}; previous history preserved`); + history = observe(history, (await response.json()).stargazers_count); + writeJSON(file, history); + } + const svg = path.resolve(args.svg || path.join(path.dirname(file), 'stars.svg')); + fs.mkdirSync(path.dirname(svg), { recursive: true }); + fs.writeFileSync(svg, renderSVG(history)); + console.log(JSON.stringify({ status: 'passed', observations: history.observations.length, latest: history.observations.at(-1) || null })); +}); diff --git a/scripts/release/tooling.mjs b/scripts/release/tooling.mjs new file mode 100644 index 0000000..58a16e2 --- /dev/null +++ b/scripts/release/tooling.mjs @@ -0,0 +1,18 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { readJSON } from './lib.mjs'; + +export function readTooling(root) { + const value = readJSON(path.join(root, 'release/tooling.json')); + if (value.schema_version !== 1) throw new Error('Unsupported tooling manifest'); + for (const [key, repository] of [['workspace', 'shendeguize/AgentOrganon'], ['core', 'shendeguize/OrganonCore']]) { + if (!value[key] && key === 'core') continue; + if (value[key]?.repository !== repository || !/^[a-f0-9]{40}$/.test(value[key]?.commit)) throw new Error(`Invalid fixed tooling source: ${key}`); + } + return value; +} +if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) { + const value = readTooling(process.argv[2] || '.'); + if (!process.env.GITHUB_OUTPUT) throw new Error('Tooling outputs require GitHub Actions'); + for (const key of ['workspace', 'core']) if (value[key]) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value[key].commit}\n`); +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..043dd46 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,12 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const directory = path.join(root, 'tests'); +const files = fs.existsSync(directory) ? fs.readdirSync(directory).filter(name => /\.test\.(?:mjs|js)$/.test(name)).sort().map(name => path.join(directory, name)) : []; +if (!files.length) throw new Error('No owned tests found'); +const result = spawnSync(process.execPath, ['--test', '--test-concurrency=2', ...process.argv.slice(2), ...files], { stdio: 'inherit', cwd: root }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/site/index.md b/site/index.md new file mode 100644 index 0000000..6888f46 --- /dev/null +++ b/site/index.md @@ -0,0 +1,35 @@ +--- +layout: home +hero: + name: "AgentOrganon" + text: "Ground agent judgments and improvements." + tagline: "Workspace methods and philosophy management · Make commitments explicit. Examine grounds. Revise for reasons." + actions: + - theme: brand + text: "Quick start" + link: /quick-start + - theme: alt + text: "Read the philosophy" + link: /philosophy +features: + - title: "Make the baseline explicit" + details: "Distinguish adopted commitments, a proposed change and the method’s own constraints." + - title: "Examine reasons and limits" + details: "Connect a claim to its assumptions, grounds and scope of application." + - title: "Revise for stated reasons" + details: "Examine reasons for change; retain an explicit human decision on philosophical adoption." +--- + +## Why philosophy as a design foundation + +Agents make judgments and propose changes. A philosophy makes their underlying commitments readable, open to criticism and revisable. Organon supplies philosophical text and review methods that ask for reasons and limits. It does not guarantee correct judgments or treat a count of methods as evidence of capability. + +## Three repositories, distinct responsibilities + +| Repository | Responsibility | +| --- | --- | +| [AgentOrganon](https://shendeguize.github.io/AgentOrganon/) | Workspace skills and management of adopted philosophy copies. | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/) | The core philosophy, review methods and bounded Lean evidence. | +| [AdvisedOrganons](https://shendeguize.github.io/AdvisedOrganons/) | A collection of domain philosophies; select a domain explicitly. | + +Installation packages supply non-Lean capabilities; the website retains the philosophy and Lean reader views. diff --git a/site/lean.md b/site/lean.md new file mode 100644 index 0000000..193323f --- /dev/null +++ b/site/lean.md @@ -0,0 +1,10 @@ +# Lean reader directory + +Begin with claims, assumptions and limits, then inspect proof declarations and paired code with line explanations. These pages belong to each philosophy’s source repository; AgentOrganon does not maintain a separate formalization. + +| Source | Content | +| --- | --- | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/lean) | Core targets, statuses, source tracing and paired line explanations. | +| [SoftwareEngineering](https://shendeguize.github.io/AdvisedOrganons/lean) | Domain philosophy with its retained Core baseline; no automatic adoption of a newer Core. | + +Lean `passed`, target `accepted` and source fidelity are reported separately. Bounded results do not establish universal real-world correctness; defining a duty does not prove its fulfillment. Installation packages exclude Lean capabilities; full projects, evidence and checking instructions remain in the source repositories. diff --git a/site/philosophy.md b/site/philosophy.md new file mode 100644 index 0000000..991972c --- /dev/null +++ b/site/philosophy.md @@ -0,0 +1,9 @@ +# Sources of the philosophy + +AgentOrganon uses the Core philosophy for its own maintenance. Workspace assessment uses your explicitly selected adopted philosophy; the bundled Core cannot silently replace it. + +- [Read the Core philosophy](https://shendeguize.github.io/OrganonCore/philosophy) +- [Select a domain philosophy](https://shendeguize.github.io/AdvisedOrganons/philosophy) +- [Understand baselines, proposals and method constraints](understand.md) + +The root `PHILOSOPHY.md` is a read-only source link; workspace adoption uses a separate regular file. Installation updates do not automatically change an adopted philosophy. diff --git a/site/public/assets/stars.json b/site/public/assets/stars.json new file mode 100644 index 0000000..928d329 --- /dev/null +++ b/site/public/assets/stars.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "repository": "shendeguize/AgentOrganon", + "observations": [ + { + "observed_at": "2026-09-15T04:41:11.016Z", + "total": 0 + } + ] +} diff --git a/site/public/assets/stars.svg b/site/public/assets/stars.svg new file mode 100644 index 0000000..98c16fe --- /dev/null +++ b/site/public/assets/stars.svg @@ -0,0 +1 @@ +shendeguize/AgentOrganon — observed GitHub starsDaily observed totals, including decreases. Missing dates are gaps. Last observed 2026-09-15T04:41:11.016Z: 0.GitHub stars · 0shendeguize/AgentOrganonObserved since 2026-09-15 · Updated 2026-09-15T04:41:11.016ZDaily total / 每日总数 · gaps indicate missing observations / 缺样保留断点2026-09-15T04:41:11.016Z: 0 diff --git a/site/rationale.md b/site/rationale.md new file mode 100644 index 0000000..87591a2 --- /dev/null +++ b/site/rationale.md @@ -0,0 +1,6 @@ +# Sources of the rationale + +Rationale supplies reasons and objections without adding philosophical obligations. Read it in the repository that owns the text. + +- [Core rationale](https://shendeguize.github.io/OrganonCore/rationale) +- [SoftwareEngineering rationale](https://shendeguize.github.io/AdvisedOrganons/rationale) diff --git a/site/site.json b/site/site.json new file mode 100644 index 0000000..c719cde --- /dev/null +++ b/site/site.json @@ -0,0 +1,7 @@ +{ + "repository": "AgentOrganon", + "product": "agent-organon", + "version": "1.0.0-rc.1", + "themeVersion": "1.0.0-rc.1", + "philosophyRoot": null +} diff --git a/site/zh/index.md b/site/zh/index.md new file mode 100644 index 0000000..53c7bd5 --- /dev/null +++ b/site/zh/index.md @@ -0,0 +1,35 @@ +--- +layout: home +hero: + name: "AgentOrganon" + text: "让 agent 的判断与改进有依据。" + tagline: "工作区方法与哲学管理 · 明确承诺,审查根据,有理由地修订。" + actions: + - theme: brand + text: "快速开始" + link: /zh/quick-start + - theme: alt + text: "阅读哲学" + link: /zh/philosophy +features: + - title: "明确判断基准" + details: "区分已采纳的承诺、待审查的提议与方法自身的约束。" + - title: "审查理由与限度" + details: "将主张与前提、根据及适用范围联系起来。" + - title: "有理由地修订" + details: "审查改变的理由;哲学采纳保留明确的人类决定。" +--- + +## 为什么采用哲学设计 + +Agent 会进行判断,也会提出改变。哲学让这些判断所依据的承诺可读、可质疑、可修订。Organon 提供哲学文本与审查方法,要求说明理由和边界;它不保证每次判断正确,也不把方法数量当作能力证明。 + +## 三个仓库,各有职责 + +| 仓库 | 职责 | +| --- | --- | +| [AgentOrganon](https://shendeguize.github.io/AgentOrganon/zh/) | 工作区技能与已采纳哲学副本的管理。 | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/zh/) | 核心哲学、审查方法,以及有边界的 Lean 证据。 | +| [AdvisedOrganons](https://shendeguize.github.io/AdvisedOrganons/zh/) | 领域哲学集合;使用时显式选择领域。 | + +安装包只提供非 Lean 能力;网站保留完整哲学及 Lean 读者视图。 diff --git a/site/zh/lean.md b/site/zh/lean.md new file mode 100644 index 0000000..016e3c0 --- /dev/null +++ b/site/zh/lean.md @@ -0,0 +1,10 @@ +# Lean 读者入口 + +从主张、前提和限制读起,再查看证明声明及逐行代码解释。以下页面来自各哲学所属仓库,AgentOrganon 不维护另一份形式化结果。 + +| 本源 | 内容 | +| --- | --- | +| [OrganonCore](https://shendeguize.github.io/OrganonCore/zh/lean) | 核心哲学的目标、状态、来源追踪与逐行对照。 | +| [SoftwareEngineering](https://shendeguize.github.io/AdvisedOrganons/zh/lean) | 领域哲学及其保留的 Core 基准,不自动采纳新 Core。 | + +Lean `passed`、目标 `accepted` 和来源保真状态分别报告。有限结果不证明现实中的普遍正确性;定义义务不证明义务已履行。安装包不包含 Lean 能力,完整工程、证据和检查方式保留在源仓库。 diff --git a/site/zh/philosophy.md b/site/zh/philosophy.md new file mode 100644 index 0000000..62fb656 --- /dev/null +++ b/site/zh/philosophy.md @@ -0,0 +1,9 @@ +# 哲学的来源 + +AgentOrganon 使用 Core 哲学作为自身维护基准。工作区评估使用你显式选择的已采纳哲学,不能自动用捆绑的 Core 替代。 + +- [阅读 Core 哲学](https://shendeguize.github.io/OrganonCore/zh/philosophy) +- [选择领域哲学](https://shendeguize.github.io/AdvisedOrganons/zh/philosophy) +- [理解基准、提议和方法约束](understand.md) + +根目录 `PHILOSOPHY.md` 是只读源链接;工作区采纳使用独立的普通文件。安装更新不自动改变已采纳哲学。 diff --git a/site/zh/rationale.md b/site/zh/rationale.md new file mode 100644 index 0000000..a11fff8 --- /dev/null +++ b/site/zh/rationale.md @@ -0,0 +1,6 @@ +# 论证的来源 + +Rationale 提供理由和反对意见,不向哲学核心增加义务。请在文本所属仓库中阅读。 + +- [Core 论证](https://shendeguize.github.io/OrganonCore/zh/rationale) +- [SoftwareEngineering 论证](https://shendeguize.github.io/AdvisedOrganons/zh/rationale) diff --git a/skills/organon-absorb/SKILL.md b/skills/organon-absorb/SKILL.md index a0bed17..d106a5f 100644 --- a/skills/organon-absorb/SKILL.md +++ b/skills/organon-absorb/SKILL.md @@ -8,4 +8,4 @@ description: Examine reasons to revise the calling workspace's adopted philosoph 1. Resolve the philosophy using [the resolution contract](../../OrganonCore/skills/references/philosophy-resolution.md). Run `node /scripts/resolve.js --cwd ` with `--philosophy ` when explicitly supplied. Locate `` from this skill's directory; preserve the caller's directory for resolution. 2. If resolution fails, stop. For missing default candidates, suggest `organon-philosophy init`; an invalid explicit path must be corrected, not replaced by a default or Core. 3. Read the selected `core_version` and compare it with `organon.coreVersion` in [package.json](../../package.json). Surface the resolver's source-version warning when outside the range. If metadata cannot be read, report the version as unknown and retain the selected text as the assessment baseline; managed writes still require valid supported metadata. These warnings are not philosophical validity judgments. -4. Delegate the request to [organon-core-absorb](../../OrganonCore/skills/organon-core-absorb/SKILL.md), passing the resolved philosophy path as both the fixed adopted baseline and the intended adoption target, with its real source directory. Keep a proposed replacement and Core method constraints distinct. Apply Core's required independent review and concrete user decision to that target; a read-only Core symlink requires the explicit Core workflow rather than a management write. +4. Delegate the request to [organon-core-absorb](../../OrganonCore/skills/organon-core-absorb/SKILL.md), passing the resolved philosophy path as the fixed adopted baseline, with its real source directory. Identify any intended adoption target from the request and caller context; pass any established target and the allowed actions without substituting the baseline for a different target. Resolve relative target paths from the caller's directory. A request to revise the caller's own adopted text may use the same path for both roles. For an advisory request without an established target, keep the target unspecified and continue the authorized review; resolution alone does not select a write destination. Keep a proposed replacement and Core method constraints distinct. Apply Core's required independent review and concrete user decision before adoption. Reading a Core symlink as the baseline does not select Core as the adoption target; an actual request to revise protected Core text requires its explicit maintenance or absorption workflow, not a management write or symlink bypass. diff --git a/tests/agents-harness.test.mjs b/tests/agents-harness.test.mjs new file mode 100644 index 0000000..33c7b0f --- /dev/null +++ b/tests/agents-harness.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { assertRunner, settings, isolatedEnv, redact, capture, commandArgs, observedModels, outputFile, artifact, caseTasks, SKILLS } from '../scripts/agents/core.mjs'; +import { validateApprovedAgentReport, collectEvidenceClosure, fetchEvidenceClosure } from '../scripts/release/evidence.mjs'; +import { run } from '../scripts/agents/run.mjs'; +import { validateReceipt } from '../scripts/agents/review.mjs'; +import { sourceIdentity } from '../scripts/release/manifest.mjs'; +import { PRODUCTS, digest, sha256 } from '../scripts/release/lib.mjs'; + +const temporary = t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-agent-unit-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; }; +const fakeManifest = () => { + const m = { schema_version: 1, version: '1.0.0-rc.1', channel: 'rc', state: 'prepared', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([key, p]) => [key, { repository: 'shendeguize/' + p.repo, commit: '1'.repeat(40), dirty: false }])), artifacts: {} }; + m.source_digest = sourceIdentity(m); return m; +}; +const fakeCI = m => ({ GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REPOSITORY: 'shendeguize/AgentOrganon', RUNNER_TEMP: '/temporary-runner', GITHUB_REF: 'refs/heads/main', RUNNER_OS: 'Linux', GITHUB_SHA: '1'.repeat(40), RELEASE_MANIFEST_SHA256: digest(m) }); + +test('actual runner boundary rejects local, PR, WSL, wrong source and manifest identities', () => { + const m = fakeManifest(), env = fakeCI(m); + assert.doesNotThrow(() => assertRunner(env, m, 'linux')); + for (const change of [{ GITHUB_ACTIONS: undefined }, { RUNNER_ENVIRONMENT: 'self-hosted' }, { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_REF: 'refs/heads/feature' }, { GITHUB_REPOSITORY: 'attacker/repo' }, { RUNNER_OS: 'Windows' }, { WSL_DISTRO_NAME: 'Ubuntu' }, { GITHUB_SHA: '2'.repeat(40) }, { RELEASE_MANIFEST_SHA256: '0'.repeat(64) }]) assert.throws(() => assertRunner({ ...env, ...change }, m, 'linux')); + assert.throws(() => assertRunner({ ...env, RUNNER_OS: 'Windows' }, m, 'linux'), /Native/); +}); + +test('each child gets only its own key and fresh directories, not parent auth or hooks', t => { + const root = temporary(t); + const source = { PATH: process.env.PATH, OPENAI_API_KEY: 'secret-openai', ANTHROPIC_API_KEY: 'secret-anthropic', CURSOR_API_KEY: 'secret-cursor', COPILOT_GITHUB_TOKEN: 'secret-copilot', GEMINI_API_KEY: 'secret-gemini', GITHUB_TOKEN: 'repository-token', GH_TOKEN: 'gh-token', NODE_OPTIONS: '--require=evil.cjs', HOME: '/real-user', CODEX_HOME: '/real-codex', OPENCODE_MODEL: 'openai/explicit-model' }; + for (const [agent, spec] of Object.entries(settings.agents)) { + const home = path.join(root, agent), env = isolatedEnv(agent, home, source, { auth: true }); + assert.equal(env.HOME, home); assert.equal(env.USERPROFILE, home); assert(env.npm_config_cache.startsWith(home)); + assert.equal(env[spec.child_secret], source[spec.secret]); + for (const key of ['GH_TOKEN', 'GITHUB_TOKEN', 'NODE_OPTIONS', ...Object.values(settings.agents).map(x => x.child_secret).filter(x => x !== spec.child_secret)]) assert.equal(env[key], undefined); + assert.equal(isolatedEnv(agent, home, source)[spec.child_secret], undefined); + } + assert.throws(() => isolatedEnv('codex', root, {}, { auth: true }), /authentication/); + assert.throws(() => isolatedEnv('opencode', root, { OPENAI_API_KEY: 'key' }, { auth: true }), /OPENCODE_MODEL/); +}); + +test('fixture child raw capture redacts secrets, preserves failures and times out entire run', async t => { + const home = temporary(t), env = isolatedEnv('codex', home, process.env), secret = 'fixture-secret-12345'; + const good = await capture(process.execPath, ['-e', 'process.stdout.write(process.argv[1]); process.stderr.write("fixture stderr")', secret], { cwd: home, env, secrets: [secret] }); + assert.equal(good.exit_code, 0); assert.equal(good.stdout, '[REDACTED]'); assert.equal(good.stderr, 'fixture stderr'); + assert(!JSON.stringify(good).includes(secret)); + const failed = await capture(process.execPath, ['-e', 'process.stderr.write("permission denied"); process.exit(7)'], { cwd: home, env }); + assert.equal(failed.exit_code, 7); assert.match(failed.stderr, /permission denied/); + const timeout = await capture(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { cwd: home, env, timeout: 50 }); + assert.equal(timeout.timed_out, true); assert.notEqual(timeout.exit_code, 0); + const absent = await capture(path.join(home, 'missing-tool'), [], { cwd: home, env }); + assert.match(absent.error, /ENOENT/); + const overflow = await capture(process.execPath, ['-e', 'process.stdout.write("a".repeat(100000))'], { cwd: home, env, maxBytes: 100 }); + assert.equal(overflow.output_limit, true); +}); + +test('credential literal, encoded, JSON and base64 forms are removed', () => { + const value = 'a"b\nc?d'; + const input = [value, JSON.stringify(value), encodeURIComponent(value), Buffer.from(value).toString('base64')].join(' '); + const output = redact(input, [value]); + for (const form of [value, encodeURIComponent(value), Buffer.from(value).toString('base64')]) assert(!output.includes(form)); +}); + +test('adapter arguments preserve prompt as a single value and defaults remain unobserved', () => { + const prompt = 'arbitrary "$()" ; prompt'; + for (const agent of Object.keys(settings.agents)) { + const args = commandArgs(agent, prompt, 'explicit/model'); + assert.equal(args.filter(x => x === prompt).length, 1); assert(args.includes('explicit/model')); + } + assert.equal(commandArgs('codex', prompt).includes('--model'), false); + assert.deepEqual(observedModels('codex', '{"model":"invented","independent_reviewer":true}'), []); + assert.deepEqual(observedModels('cursor', '{"type":"system","subtype":"init","model":"observed"}'), ['observed']); + assert.deepEqual(observedModels('gemini', '{"type":"init","model":"observed"}'), ['observed']); +}); + +test('artifact paths are confined and evidence cannot be overwritten', t => { + const root = temporary(t), ref = outputFile(root, 'evidence/raw.txt', 'raw fixture'); + assert.equal(fs.readFileSync(artifact(root, ref), 'utf8'), 'raw fixture'); + assert.throws(() => outputFile(root, '../escape', 'x'), /escapes/); + assert.throws(() => outputFile(root, 'evidence/raw.txt', 'replacement'), /EEXIST/); + fs.writeFileSync(path.join(root, ref.file), 'modified'); assert.throws(() => artifact(root, ref), /mismatch/); +}); + +// This fixture exercises receipt validation only; it is never a real agent result or release artifact. +function fixture(t, matrix = 'smoke') { + const root = temporary(t), skills = matrix === 'full' ? SKILLS : ['organon-assess']; + const source = 'a'.repeat(64), packages = 'b'.repeat(64); + const cases = [], raw = [], receipts = []; + for (const id of skills) { + const text = `UNIT FIXTURE ONLY. Subject session-${id}. Model fixture-model-v1. Discovered installed ${id} through native loader. Invoked installed method ${id}. Read the explicitly selected baseline PHILOSOPHY.md. Independent initial input was frozen. Independent initial response supplied reasons. Candidate compared after preserved initial response.`; + const ref = outputFile(root, id + '-raw.txt', text); raw.push({ id, artifact: ref }); + const cite = quote => ({ artifact: ref, quote }); + cases.push({ id, skill: id, process_status: 'completed' }); + receipts.push({ id, verdict: 'accepted', findings: 'Unit fixture checks receipt structure only, not actual capability.', limits: 'This fixture is not production evidence.', discovered_path: '/fixture/skills/' + id + '/SKILL.md', discovery: cite('Discovered installed ' + id + ' through native loader.'), invocation: cite('Invoked installed method ' + id + '.'), payload_access: cite('Read the explicitly selected baseline PHILOSOPHY.md.'), subject_session: { id: 'session-' + id, evidence: cite('Subject session-' + id + '.') }, delegation: { status: 'completed', reviewer_id: 'native-fixture-reviewer', session_id: 'native-review-' + id, initial_before_candidate: true, initial_input: cite('Independent initial input was frozen.'), initial_response: cite('Independent initial response supplied reasons.'), comparison: cite('Candidate compared after preserved initial response.') } }); + } + const rawRef = outputFile(root, 'raw-index.json', { source_digest: source, artifact_digest: packages, cases: raw }), inputs = outputFile(root, 'inputs.json', { source_digest: source, artifact_digest: packages, cases }); + const report = { gate: 'agents', status: 'needs_independent_review', actual_call: true, probe: false, matrix, platform: 'linux', source_digest: source, artifact_digest: packages, run_id: 'fixture-run', tool_version: 'fixture-version', ci: { run_id: 'fixture-ci', source_commit: '1'.repeat(40) }, raw_response: rawRef, inputs, cases }; + const reportRef = outputFile(root, 'report.json', report), reportFile = path.join(root, reportRef.file); + const assessment = outputFile(root, 'assessment.txt', 'UNIT FIXTURE independent assessment. '.repeat(10)); + const reviewerInput = outputFile(root, 'reviewer-input.txt', [reportRef.sha256, rawRef.sha256, inputs.sha256].join('\n')); + const receipt = { schema_version: 1, report_sha256: reportRef.sha256, source_digest: source, artifact_digest: packages, inputs_sha256: inputs.sha256, raw_sha256: rawRef.sha256, reviewer: { kind: 'agent', id: 'independent-fixture-reviewer', session_id: 'external-fixture-session', harness_author: false, case_author: false }, assessment, reviewer_input: reviewerInput, raw_preserved_before_comparison: true, exposure: 'Fixture only; no actual provider session.', limits: 'No real agent validation.', cases: receipts, observed_model: { name: 'fixture-model-v1', evidence: { artifact: raw[0].artifact, quote: 'Model fixture-model-v1.' } } }; + return { root, reportFile, report, receipt }; +} + +test('complete smoke and exact ten-method full receipt validate without making actual calls', t => { + for (const matrix of ['smoke', 'full']) { const f = fixture(t, matrix); const result = validateReceipt(f.root, f.reportFile, f.receipt); assert.equal(result.status, 'passed'); assert.equal(result.all_non_lean_skills.length || 0, matrix === 'full' ? 10 : 0); } + assert.deepEqual(caseTasks('full').map(x => x.skill), SKILLS); assert.equal(caseTasks('smoke').length, 1); assert.equal(caseTasks('full', true)[0].skill, null); +}); + +test('receipt rejects missing review, author self-review, wrong hashes and unsupported observations', t => { + const f = fixture(t); + const mutations = [r => r.report_sha256 = 'c'.repeat(64), r => r.artifact_digest = 'c'.repeat(64), r => r.reviewer.harness_author = true, r => r.reviewer.session_id = 'fixture-run', r => r.reviewer.session_id = r.cases[0].subject_session.id, r => delete r.assessment, r => r.raw_preserved_before_comparison = false, r => r.cases[0].verdict = 'incomplete', r => r.cases[0].delegation.status = 'incomplete', r => r.cases[0].discovery.quote = 'This text is not in actual output', r => delete r.observed_model, r => r.cases.push(r.cases[0]), r => r.inputs_sha256 = '0'.repeat(64)]; + for (const mutate of mutations) { const receipt = structuredClone(f.receipt); mutate(receipt); assert.throws(() => validateReceipt(f.root, f.reportFile, receipt)); } +}); + +test('full receipt cannot drop an entry or downgrade required absorption delegation', t => { + const f = fixture(t, 'full'); + const missing = structuredClone(f.receipt); missing.cases.pop(); assert.throws(() => validateReceipt(f.root, f.reportFile, missing), /coverage/); + const incomplete = structuredClone(f.receipt); incomplete.cases.find(c => c.id === 'organon-absorb').delegation = { status: 'not_required', reason: 'not available' }; + assert.throws(() => validateReceipt(f.root, f.reportFile, incomplete), /Absorption/); +}); + +test('process failure or probe can never be converted into release success', t => { + const f = fixture(t); + for (const change of [{ status: 'failed' }, { actual_call: false }, { probe: true }, { model: null, cases: [{ ...f.report.cases[0], process_status: 'failed' }] }]) { + const report = { ...f.report, ...change }; fs.writeFileSync(f.reportFile, JSON.stringify(report)); + const receipt = { ...f.receipt, report_sha256: sha256(fs.readFileSync(f.reportFile)) }; + assert.throws(() => validateReceipt(f.root, f.reportFile, receipt)); + } +}); + + +test('seal replays exact receipt and recursively retains every independently reviewed artifact', async t => { + const f = fixture(t, 'smoke'); + const original = { file: 'report.json', sha256: sha256(fs.readFileSync(f.reportFile)) }; + const receiptRef = outputFile(f.root, 'receipt.json', f.receipt); + const approved = { ...validateReceipt(f.root, f.reportFile, f.receipt), unreviewed_report: original, review_receipt: receiptRef }; + const approvedRef = outputFile(f.root, 'approved.json', approved); + assert.doesNotThrow(() => validateApprovedAgentReport(f.root, approved)); + for (const change of [{ model: 'invented' }, { independent_reviewer: true }, { platform: 'win32' }, { all_non_lean_skills: true }, { source_digest: 'e'.repeat(64) }]) assert.throws(() => validateApprovedAgentReport(f.root, { ...approved, ...change }), /differs/); + assert.throws(() => validateApprovedAgentReport(f.root, { ...approved, unreviewed_report: undefined }), /reference/); + const manifest = { evidence: [approvedRef], artifacts: {} }; + const closure = collectEvidenceClosure(manifest, f.root); + for (const expected of ['approved.json', 'report.json', 'receipt.json', 'inputs.json', 'raw-index.json', 'organon-assess-raw.txt', 'assessment.txt', 'reviewer-input.txt']) assert(closure.some(ref => ref.file === expected), expected); + const destination = temporary(t), fetched = []; + await fetchEvidenceClosure(manifest, destination, async ref => { fetched.push(ref.file); fs.mkdirSync(path.dirname(path.join(destination, ref.file)), { recursive: true }); fs.copyFileSync(path.join(f.root, ref.file), path.join(destination, ref.file)); }); + assert.equal(fetched.length, closure.length); + assert.deepEqual(collectEvidenceClosure(manifest, destination), closure); + fs.writeFileSync(path.join(destination, 'organon-assess-raw.txt'), 'changed nested evidence'); + assert.throws(() => collectEvidenceClosure(manifest, destination), /checksum/); +}); + + +test('missing credential writes a blocked evidence bundle without spawning a provider', async t => { + const root = temporary(t), manifest = fakeManifest(); + const manifestFile = path.join(root, 'release-manifest.json'); fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + const platformName = { linux: 'Linux', darwin: 'macOS', win32: 'Windows' }[process.platform]; + const env = { ...fakeCI(manifest), RUNNER_TEMP: root, RUNNER_OS: platformName }; + const report = await run({ agent: 'codex', matrix: 'smoke', manifest: manifestFile, 'package-dir': root, out: path.join(root, 'blocked') }, env); + assert.equal(report.status, 'blocked'); assert.equal(report.actual_call, false); assert.match(report.reason, /Missing authentication/); + const raw = JSON.parse(fs.readFileSync(artifact(root, report.raw_response))); + assert.equal(raw.artifact_digest, digest(manifest.artifacts)); assert.deepEqual(raw.cases, []); + assert(fs.existsSync(path.join(root, 'blocked/report.json'))); +}); diff --git a/tests/governance-audit.test.mjs b/tests/governance-audit.test.mjs new file mode 100644 index 0000000..e613721 --- /dev/null +++ b/tests/governance-audit.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { branchRules, tagRules, sameRules, inspect, inspectReleaseGovernance } from '../scripts/release/governance.mjs'; +import { environmentPlan } from '../scripts/release/security.mjs'; + +const repository = 'shendeguize/AgentOrganon'; +function apiFixture({ hiddenBypass = false, adminBypass = false, writableToken = false } = {}) { + const rules = [branchRules(), tagRules()]; + return ([endpoint]) => { + if (endpoint.endsWith('/rulesets')) return rules.map((rule, index) => ({ id: index + 1, name: rule.name })); + if (/\/rulesets\/\d+$/.test(endpoint)) { + const rule = structuredClone(rules[Number(endpoint.split('/').at(-1)) - 1]); + if (hiddenBypass) delete rule.bypass_actors; + return rule; + } + if (endpoint.endsWith('/actions/permissions')) return { enabled: true, sha_pinning_required: true }; + if (endpoint.endsWith('/actions/permissions/workflow')) return { default_workflow_permissions: writableToken ? 'write' : 'read', can_approve_pull_request_reviews: false }; + const name = endpoint.split('/environments/')[1]?.split('/')[0]; + const wanted = environmentPlan(repository).find(item => item.name === name); + assert.ok(wanted, endpoint); + if (endpoint.endsWith('/deployment-branch-policies')) return { branch_policies: wanted.branches.map(name => ({ name, type: 'branch' })) }; + return { can_admins_bypass: adminBypass, deployment_branch_policy: { protected_branches: false, custom_branch_policies: true }, protection_rules: wanted.review ? [{ type: 'required_reviewers', prevent_self_review: false, reviewers: [{ reviewer: { login: 'shendeguize' } }] }] : [] }; + }; +} + +test('a missing bypass field is unknown, not an empty bypass list', () => { + const rule = branchRules(), hidden = structuredClone(rule); delete hidden.bypass_actors; + assert.equal(sameRules(rule, rule), true); + assert.equal(sameRules(hidden, rule), false); + assert.equal(sameRules({ ...rule, bypass_actors: [{ actor_type: 'User', actor_id: 1, bypass_mode: 'always' }] }, rule), false); +}); +test('release governance combines branch, tag, Action and environment checks', async () => { + assert.equal((await inspectReleaseGovernance(repository, apiFixture())).status, 'passed'); + for (const options of [{ hiddenBypass: true }, { adminBypass: true }, { writableToken: true }]) { + assert.equal((await inspectReleaseGovernance(repository, apiFixture(options))).status, 'failed'); + } +}); +test('GitHub server defaults and set ordering preserve the same rules', () => { + const wanted = branchRules(), actual = structuredClone(wanted); + actual.rules.reverse(); actual.conditions.ref_name.include.reverse(); + const pr = actual.rules.find(r => r.type === 'pull_request').parameters; + Object.assign(pr, { required_reviewers: [], ignore_approvals_from_contributors: false, require_extra_approval_for_unattributed_changes: true, allowed_merge_methods: ['rebase', 'merge', 'squash'] }); + actual.rules.find(r => r.type === 'required_status_checks').parameters.do_not_enforce_on_create = false; + assert.equal(sameRules(actual, wanted), true); + pr.require_extra_approval_for_unattributed_changes = false; + assert.equal(sameRules(actual, wanted), false); +}); +test('read-only bypass counts bind the exact repository and ruleset and reject hidden or nonzero counts', async () => { + const run = mutation => inspect(repository, (args, body) => { + if (args[0] !== 'graphql') { + const value = apiFixture({ hiddenBypass: true })(args); + if (/\/rulesets\/\d+$/.test(args[0])) Object.assign(value, { id: Number(args[0].split('/').at(-1)), node_id: `fixture-${args[0].split('/').at(-1)}` }); + return value; + } + const id = body.variables.databaseId; + const response = { data: { repository: { nameWithOwner: repository, ruleset: { __typename: 'RepositoryRuleset', id: `fixture-${id}`, databaseId: id, source: { __typename: 'Repository', nameWithOwner: repository }, bypassActors: { totalCount: 0, nodes: [] } } } } }; + mutation?.(response); + return response; + }); + assert.equal((await run()).status, 'passed'); + assert.equal((await run(r => { r.data.repository.ruleset.bypassActors = { totalCount: 2, nodes: [null, null] }; })).status, 'failed'); + for (const mutate of [r => { r.errors = [{ message: 'hidden' }]; }, r => { r.data.repository.ruleset.bypassActors = null; }, r => { r.data.repository.ruleset.id = 'another'; }, r => { r.data.repository.ruleset.databaseId = 8; }, r => { r.data.repository.ruleset.source.nameWithOwner = 'another/repo'; }]) await assert.rejects(run(mutate), /Cannot establish complete/); +}); +test('audit credential is removed from the environment before child execution', () => { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', 'await import("./scripts/release/governance.mjs"); if ("GOVERNANCE_AUDIT_TOKEN" in process.env) process.exit(9)'], { cwd: new URL('..', import.meta.url), env: { ...process.env, GOVERNANCE_AUDIT_TOKEN: 'fixture-only' }, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs new file mode 100644 index 0000000..c86e6d4 --- /dev/null +++ b/tests/installer.test.mjs @@ -0,0 +1,255 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { buildPackage } from '../scripts/package/build.mjs'; +import { extractTarball } from '../installer/lib/archive.mjs'; +import { sha256, serialize, write } from '../installer/lib/files.mjs'; +import { transact } from '../installer/lib/transaction.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const candidateFile = process.env.ORGANON_TEST_CANDIDATE_MANIFEST; +const candidate = candidateFile ? JSON.parse(fs.readFileSync(candidateFile, 'utf8')) : null; +const firstVersion = candidate?.version || '1.0.0-rc.1'; +const nextVersion = firstVersion.includes('-rc.') ? firstVersion.replace(/rc\.(\d+)$/, (_, n) => `rc.${Number(n) + 1}`) : '1.0.1'; +function sandbox(t) { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'organon-install-test-'))); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const home = path.join(directory, 'home'); const project = path.join(directory, 'project with spaces'); + fs.mkdirSync(home); fs.mkdirSync(project); + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|CONFIG|HOME|NODE_OPTIONS)/i.test(key))); + const env = { ...inherited, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: path.join(home, '.config'), CODEX_HOME: path.join(home, '.codex'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'), GEMINI_CLI_HOME: home, npm_config_cache: path.join(home, '.npm'), npm_config_prefix: path.join(home, '.npm-global'), npm_config_userconfig: path.join(home, '.npmrc'), npm_config_globalconfig: path.join(home, '.npmrc-global') }; + const packages = {}; + function pack(product = 'agent-organon', version = firstVersion) { + if (candidate && version === candidate.version) { + const entry = candidate.artifacts[product]; + const file = path.resolve(path.dirname(candidateFile), entry.file); + assert.equal(sha256(fs.readFileSync(file)), entry.sha256); + return file; + } + const key = `${product}-${version}`; + return packages[key] ??= buildPackage({ product, version, out: path.join(directory, 'artifacts') }).tarball; + } + const runtime = candidate ? path.join(extractTarball(pack(), path.join(directory, 'candidate-runtime')), 'installer/organon.mjs') : path.join(root, 'installer/organon.mjs'); + function cli(command, extra = [], expected = 0, product = 'agent-organon', executable = runtime) { + const result = spawnSync(process.execPath, [executable, command, '--product', product, '--agent', 'codex', '--scope', 'project', '--project', project, ...extra], { cwd: project, env, encoding: 'utf8' }); + assert.equal(result.status, expected, result.stderr || result.stdout); + return JSON.parse(expected ? result.stderr : result.stdout); + } + return { directory, home, project, env, pack, cli, runtime }; +} + +test('three extracted packages install offline from an empty directory and expose distinct skills', t => { + const s = sandbox(t); + const names = new Set(); + for (const product of ['agent-organon', 'core', 'advised']) { + const extracted = extractTarball(s.pack(product), path.join(s.directory, `extract-${product}`)); + const readme = fs.readFileSync(path.join(extracted, 'README.md'), 'utf8'); + const example = readme.match(/`(node installer\/organon\.mjs install [^`]+)`/)[1].split(' ').slice(1).map(value => value === '/path/to/project' ? s.project : value); + const execution = spawnSync(process.execPath, example, { cwd: extracted, env: s.env, encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr); + const result = JSON.parse(execution.stdout); + assert.equal(result.action, 'installed'); + for (const skill of result.discovery.codex) { assert.ok(!names.has(skill.skill)); names.add(skill.skill); } + const checked = s.cli('check', [], 0, product, path.join(extracted, 'installer/organon.mjs')); + assert.equal(checked.agent.authentication, 'not-checked'); + } + assert.ok(!fs.existsSync(path.join(s.project, 'PHILOSOPHY.md'))); + assert.ok(!fs.existsSync(path.join(s.home, '.agents'))); +}); + +test('idempotent install, exact update, rollback and immutable adoption reference closure', t => { + const s = sandbox(t); + const first = s.cli('install', ['--from', s.pack()]); + assert.equal(s.cli('install', ['--from', s.pack()]).action, 'unchanged'); + const preview = s.cli('init', ['--philosophy', 'core']); + assert.equal(preview.action, 'preview'); assert.ok(!fs.existsSync(preview.target)); + write(path.join(s.project, 'AGENTS.md'), 'User instructions.\n'); + s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', s.cli('init', ['--philosophy', 'core']).planSha256]); + const adopted = fs.readFileSync(preview.target, 'utf8'); + assert.match(fs.readFileSync(path.join(s.project, 'AGENTS.md'), 'utf8'), /^User instructions\.\n/); + const rationale = adopted.match(/\[Rationale\]\(([^)]+)\)/)[1]; + assert.ok(fs.existsSync(path.resolve(s.project, decodeURIComponent(rationale)))); + const updated = s.cli('update', ['--from', s.pack('agent-organon', nextVersion)]); + assert.equal(updated.version, nextVersion); + assert.equal(s.cli('rollback').version, firstVersion); + assert.equal(s.cli('check').directory, first.directory); + s.cli('uninstall'); + assert.equal(fs.readFileSync(preview.target, 'utf8'), adopted); + assert.ok(fs.existsSync(path.resolve(s.project, decodeURIComponent(rationale)))); +}); + +test('modified discovery blocks update and survives uninstall with its runtime', t => { + const s = sandbox(t); + const installed = s.cli('install', ['--from', s.pack()]); + const file = installed.discovery.codex[0].file; + fs.appendFileSync(file, '\nUser modification.\n'); + assert.match(s.cli('update', ['--from', s.pack('agent-organon', nextVersion)], 1).error, /modified/); + const result = s.cli('uninstall'); + assert.ok(result.retained.includes(file)); assert.ok(fs.existsSync(installed.directory)); + assert.match(fs.readFileSync(file, 'utf8'), /User modification/); +}); + +test('modified payload blocks check/update and is retained by uninstall', t => { + const s = sandbox(t); + const installed = s.cli('install', ['--from', s.pack()]); + fs.appendFileSync(path.join(installed.directory, 'payload/OrganonCore/PHILOSOPHY.md'), '\nModified.\n'); + assert.match(s.cli('check', [], 1).error, /integrity/); + assert.match(s.cli('update', ['--from', s.pack('agent-organon', nextVersion)], 1).error, /integrity/); + assert.ok(s.cli('uninstall').retained.includes(installed.directory)); +}); + +test('existing project philosophy and destination symlinks are not overwritten', t => { + const s = sandbox(t); + s.cli('install', ['--from', s.pack()]); + write(path.join(s.project, 'PHILOSOPHY.md'), 'Existing adopted text.\n'); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply'], 1).error, /Existing philosophy/); + assert.equal(fs.readFileSync(path.join(s.project, 'PHILOSOPHY.md'), 'utf8'), 'Existing adopted text.\n'); + const other = path.join(s.directory, 'other-project'); fs.mkdirSync(other); + fs.symlinkSync(s.home, path.join(other, '.agents'), process.platform === 'win32' ? 'junction' : 'dir'); + const result = spawnSync(process.execPath, [path.join(root, 'installer/organon.mjs'), 'install', '--product', 'agent-organon', '--agent', 'codex', '--scope', 'project', '--project', other, '--from', s.pack()], { env: s.env, encoding: 'utf8' }); + assert.equal(result.status, 1); assert.match(result.stderr, /Symbolic link/); + assert.ok(!fs.existsSync(path.join(s.home, 'skills'))); +}); + +test('npm offline installation registers the sole working organon executable', t => { + const s = sandbox(t); + const npmCli = process.env.npm_execpath ?? (process.platform === 'win32' + ? path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js') + : fs.realpathSync(execFileSync('which', ['npm'], { encoding: 'utf8' }).trim())); + const result = spawnSync(process.execPath, [npmCli, 'install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--prefix', s.project, s.pack()], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const bin = path.join(s.project, 'node_modules/.bin', process.platform === 'win32' ? 'organon.cmd' : 'organon'); + assert.ok(fs.existsSync(bin)); + const help = process.platform === 'win32' + ? spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '& $env:ORGANON_TEST_BIN --help'], { env: { ...s.env, ORGANON_TEST_BIN: bin }, cwd: s.project, encoding: 'utf8' }) + : spawnSync(process.execPath, [bin, '--help'], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(help.status, 0, help.stderr); assert.match(JSON.parse(help.stdout).help, /install\|check\|init/); +}); + +test('recovery refuses independent edits and preserves its journal', t => { + const s = sandbox(t); const installed = s.cli('install', ['--from', s.pack()]); + const file = installed.discovery.codex[0].file; + const before = fs.readFileSync(file).toString('base64'); + const after = Buffer.from('Candidate.').toString('base64'); + const journal = path.join(s.project, '.organon/transaction.json'); + write(journal, serialize({ schema: 1, operations: [{ path: file, before, after }] })); + fs.writeFileSync(file, 'Independent user edit.'); + assert.match(s.cli('install', ['--from', s.pack()], 1).error, /Recovery conflict/); + assert.equal(fs.readFileSync(file, 'utf8'), 'Independent user edit.'); assert.ok(fs.existsSync(journal)); +}); + +test('interrupted multi-file activation recovers before rerunning installation', t => { + const s = sandbox(t); + const installed = s.cli('install', ['--from', s.pack()]); + const file = installed.discovery.codex[0].file; + const before = fs.readFileSync(file).toString('base64'); + const after = Buffer.from('Interrupted candidate.').toString('base64'); + write(path.join(s.project, '.organon/transaction.json'), serialize({ schema: 1, operations: [{ path: file, before, after }] })); + fs.writeFileSync(file, Buffer.from(after, 'base64')); + assert.match(s.cli('check', [], 1).error, /Interrupted/); + const result = s.cli('install', ['--from', s.pack()]); + assert.equal(result.recovered, true); assert.equal(fs.readFileSync(file).toString('base64'), before); + s.cli('check'); +}); + +test('all six adapters install to native paths, retain coinstallation and report alias/scope duplication', t => { + const s = sandbox(t); const archive = s.pack(); + const command = (action, agent, scope = 'project') => { + const args = [path.join(root, 'installer/organon.mjs'), action, '--product', 'agent-organon', '--agent', agent, '--scope', scope, '--project', s.project, ...(action === 'install' ? ['--from', archive] : [])]; + const result = spawnSync(process.execPath, args, { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); + }; + for (const agent of ['codex', 'claude', 'cursor', 'copilot', 'gemini', 'opencode']) { + assert.equal(command('install', agent).action, 'installed'); command('check', agent); + } + assert.ok(command('check', 'copilot').warnings.some(item => item.type === 'alias-discovery')); + command('install', 'codex', 'global'); + assert.ok(command('check', 'codex').warnings.some(item => item.type === 'cross-scope-discovery')); + command('uninstall', 'claude'); command('check', 'codex'); + assert.ok(fs.existsSync(path.join(s.home, '.agents/skills/organon-assess/SKILL.md'))); +}); + +test('explicit domain adoption retains the independent Core checkpoint', t => { + const s = sandbox(t); + s.cli('install', ['--from', s.pack('advised'), '--domain', 'SoftwareEngineering'], 0, 'advised'); + assert.match(s.cli('init', ['--philosophy', 'SoftwareEngineering', '--apply'], 1, 'advised').error, /Select/); + const preview = s.cli('init', ['--philosophy', 'SoftwareEngineering', '--domain', 'SoftwareEngineering'], 0, 'advised'); + s.cli('init', ['--philosophy', 'SoftwareEngineering', '--domain', 'SoftwareEngineering', '--apply', '--plan-sha256', preview.planSha256], 0, 'advised'); + const text = fs.readFileSync(path.join(s.project, 'PHILOSOPHY.md'), 'utf8'); + assert.match(text, /core_version: 0\.1\.2/); +}); + +test('installed management commands operate without source repository access', t => { + const s = sandbox(t); const installed = s.cli('install', ['--from', s.pack()]); + s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', s.cli('init', ['--philosophy', 'core']).planSha256]); + const payload = path.join(installed.directory, 'payload'); + const result = spawnSync(process.execPath, [path.join(payload, 'scripts/resolve.js'), '--cwd', s.project], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).selectedPath, path.join(s.project, 'PHILOSOPHY.md')); + const check = spawnSync(process.execPath, [path.join(payload, 'scripts/check.js'), path.join(s.project, 'PHILOSOPHY.md')], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(check.status, 0, check.stderr); + const management = fs.readFileSync(path.join(s.project, '.agents/skills/organon-philosophy/SKILL.md'), 'utf8'); + assert.ok(!/node scripts\//.test(management)); assert.ok(management.includes(payload)); +}); + +test('adoption apply binds the preceding preview across instruction and package changes', t => { + const s = sandbox(t); s.cli('install', ['--from', s.pack()]); + const first = s.cli('init', ['--philosophy', 'core']); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply'], 1).error, /plan/); + write(path.join(s.project, 'AGENTS.md'), 'Changed after preview.\n'); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', first.planSha256], 1).error, /plan/); + const second = s.cli('init', ['--philosophy', 'core']); + s.cli('update', ['--from', s.pack('agent-organon', nextVersion)]); + assert.match(s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', second.planSha256], 1).error, /plan/); + assert.ok(!fs.existsSync(path.join(s.project, 'PHILOSOPHY.md'))); + const final = s.cli('init', ['--philosophy', 'core']); + s.cli('init', ['--philosophy', 'core', '--apply', '--plan-sha256', final.planSha256]); +}); + +test('an edit after journal creation is not overwritten by activation or recovery', t => { + const s = sandbox(t); const store = path.join(s.project, '.organon'); fs.mkdirSync(store); + const file = path.join(s.project, 'owned.txt'); write(file, 'Original.'); + const originalWrite = fs.writeFileSync; + fs.writeFileSync = function (destination, ...args) { + const result = originalWrite.call(fs, destination, ...args); + if (destination === path.join(store, 'transaction.json')) originalWrite.call(fs, file, 'Concurrent user edit.'); + return result; + }; + try { assert.throws(() => transact(store, s.project, [{ file, content: 'Candidate.' }]), /Recovery conflict/); } + finally { fs.writeFileSync = originalWrite; } + assert.equal(fs.readFileSync(file, 'utf8'), 'Concurrent user edit.'); + assert.ok(fs.existsSync(path.join(store, 'transaction.json'))); +}); + +test('Claude and Gemini adoption uses native project instructions for project and global installations', t => { + for (const [agent, name] of [['claude', 'CLAUDE.md'], ['gemini', 'GEMINI.md']]) { + for (const scope of ['project', 'global']) { + const s = sandbox(t); + const run = (command, extra = [], expected = 0) => { + const result = spawnSync(process.execPath, [s.runtime, command, '--product', 'agent-organon', '--agent', agent, '--scope', scope, '--project', s.project, ...extra], { env: s.env, cwd: s.project, encoding: 'utf8' }); + assert.equal(result.status, expected, result.stderr || result.stdout); + return JSON.parse(expected ? result.stderr : result.stdout); + }; + const native = path.join(s.project, name); const other = path.join(s.project, 'AGENTS.md'); + write(native, 'Existing native instructions.\n'); write(other, 'Other instructions.\n'); + run('install', ['--from', s.pack()]); + const preview = run('init', ['--philosophy', 'core']); + assert.equal(preview.instructions, native); + assert.equal(fs.readFileSync(native, 'utf8'), 'Existing native instructions.\n'); + fs.appendFileSync(native, 'Changed since preview.\n'); + assert.match(run('init', ['--philosophy', 'core', '--apply', '--plan-sha256', preview.planSha256], 1).error, /plan/); + const fresh = run('init', ['--philosophy', 'core']); + run('init', ['--philosophy', 'core', '--apply', '--plan-sha256', fresh.planSha256]); + const adopted = fs.readFileSync(native, 'utf8'); + assert.match(adopted, /^Existing native instructions\.\nChanged since preview\.\n/); + assert.match(adopted, /\[PHILOSOPHY\.md\]\(PHILOSOPHY\.md\)/); + assert.equal(fs.readFileSync(other, 'utf8'), 'Other instructions.\n'); + assert.ok(!fs.existsSync(path.join(s.home, name))); + run('uninstall'); + assert.equal(fs.readFileSync(native, 'utf8'), adopted); + } + } +}); diff --git a/tests/management.test.js b/tests/management.test.js index 51bf751..80cb2c1 100644 --- a/tests/management.test.js +++ b/tests/management.test.js @@ -48,9 +48,9 @@ test('source checks cover 22 matching bilingual IDs; root mirror needs no lock', assert.equal(english.sections.length, 22); assert.deepEqual(english.sections.map(s => s.id), chinese.sections.map(s => s.id)); assert.equal(english.sections.map(s => s.raw).join(''), english.body); - // Core 0.1.3 after the approved Assessment clarification; change only with a reviewed text revision. - assert.equal(hash(english.body.replace(/^\n/gm, '')), '41496fa241aa3c3cb6e3abc83992f904f4cbc4555f6fee935b72fc799fe06ee1'); - assert.equal(hash(chinese.body.replace(/^\n/gm, '')), '27d81360a31a44fa207bcee48d08b09da18d2f0d58beb592879b779ac5ad9cf5'); + // Core 0.1.4 after the approved rationale-path maintenance; change only with a reviewed text revision. + assert.equal(hash(english.body.replace(/^\n/gm, '')), '7e77824910fc1b62662dd6163e257b49c1d5120e900c59b458c39e9b5e233c81'); + assert.equal(hash(chinese.body.replace(/^\n/gm, '')), '0e3cb43ba80ed52cd68d39c9a4acbb91dc946321450514c91e212e21f6cac5f7'); assert.equal(check(CORE_FILE, { source: true }).mode, 'source'); assert.equal(check(path.join(ROOT, 'PHILOSOPHY.md'), { source: true }).realPath, fs.realpathSync(CORE_FILE)); assert.equal(fs.existsSync(path.join(ROOT, 'PHILOSOPHY.lock.json')), false); diff --git a/tests/package.test.mjs b/tests/package.test.mjs new file mode 100644 index 0000000..5d10e61 --- /dev/null +++ b/tests/package.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { buildPackage } from '../scripts/package/build.mjs'; +import { checkPackage } from '../scripts/package/check.mjs'; +import { extractTarball } from '../installer/lib/archive.mjs'; +import { verifyPackage, openPackage } from '../installer/lib/package.mjs'; + +test('release payloads are reproducible, symlink-free, complete and omit Lean/docs/tests', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-package-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const installerHashes = []; + for (const product of ['agent-organon', 'core', 'advised']) { + const first = buildPackage({ product, out: directory }); + const second = buildPackage({ product, out: path.join(directory, 'second') }); + assert.equal(first.sha256, second.sha256); + assert.equal(checkPackage(first.tarball).passed, true); + const extracted = extractTarball(first.tarball, path.join(directory, product)); + const manifest = verifyPackage(extracted); + assert.ok(manifest.files.every(entry => !entry.path.includes('/lean/') && !entry.path.includes('/docs/'))); + const mirror = manifest.files.find(entry => entry.path === 'payload/PHILOSOPHY.md'); + assert.ok(mirror.source.transformations.includes('materialize-symbolic-link')); + const metadata = JSON.parse(fs.readFileSync(path.join(extracted, 'package.json'))); + assert.equal(Boolean(metadata.bin), product === 'agent-organon'); + installerHashes.push(manifest.files.filter(entry => entry.path.startsWith('installer/')).map(entry => [entry.path, entry.sha256])); + fs.appendFileSync(path.join(extracted, 'payload/PHILOSOPHY.md'), '\nTampered\n'); + assert.throws(() => verifyPackage(extracted), /integrity/); + } + assert.deepEqual(installerHashes[0], installerHashes[1]); assert.deepEqual(installerHashes[1], installerHashes[2]); +}); + +test('a bundled installer never fetches another product implicitly', async t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-offline-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const archive = buildPackage({ product: 'core', out: directory }).tarball; + const bundled = extractTarball(archive, path.join(directory, 'extracted')); + const originalFetch = globalThis.fetch; + let called = false; + globalThis.fetch = () => { called = true; throw new Error('Unexpected network request'); }; + try { + const local = await openPackage({ product: 'core', bundled }); + assert.equal(local.manifest.product, 'core'); local.close(); + await assert.rejects(openPackage({ product: 'agent-organon', bundled }), /exact --version/); + assert.equal(called, false); + } finally { globalThis.fetch = originalFetch; } +}); + +test('archive extraction rejects traversal, links and checksum tampering before writing', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-archive-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const archive = buildPackage({ product: 'core', out: directory }).tarball; + for (const mutation of ['traversal', 'link', 'checksum']) { + const bytes = gunzipSync(fs.readFileSync(archive)); + if (mutation === 'traversal') { bytes.fill(0, 0, 100); bytes.write('package/../../escape', 0); } + if (mutation === 'link') bytes.write('2', 156); + if (mutation !== 'checksum') { + bytes.fill(32, 148, 156); + const sum = bytes.subarray(0, 512).reduce((total, byte) => total + byte, 0); + bytes.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148); + } else bytes[10] ^= 1; + const bad = path.join(directory, `${mutation}.tgz`); fs.writeFileSync(bad, gzipSync(bytes)); + const destination = path.join(directory, mutation); + assert.throws(() => extractTarball(bad, destination), /Unsafe|Unsupported|checksum/); + assert.ok(!fs.existsSync(destination)); + } +}); diff --git a/tests/release-attempts.test.mjs b/tests/release-attempts.test.mjs new file mode 100644 index 0000000..f9e6555 --- /dev/null +++ b/tests/release-attempts.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertTrustedRun, assertArtifactIdentity, selectAttemptArtifacts, resolveAttempt } from '../scripts/release/actions-artifacts.mjs'; +import { AGENTS } from '../scripts/release/lib.mjs'; +const run = (workflow = 'candidate.yml', attempt = 2) => ({ id: 10, run_attempt: attempt, head_sha: 'a'.repeat(40), repository: { full_name: 'shendeguize/AgentOrganon' }, head_repository: { full_name: 'shendeguize/AgentOrganon' }, path: '.github/workflows/' + workflow, event: 'workflow_dispatch', conclusion: 'success' }); +const artifact = (name, id = 100) => ({ id, name, expired: false, digest: 'sha256:' + 'b'.repeat(64), workflow_run: { id: 10, head_sha: 'a'.repeat(40) } }); +const candidate = attempt => [`candidate-packages-${attempt}`, ...['Linux', 'macOS', 'Windows'].map(os => `install-${os}-${attempt}`)].map((name, i) => artifact(name, attempt * 100 + i)); + +test('complete latest candidate attempt selects exact IDs and never borrows old successful assets', () => { + const current = candidate(2), all = [...candidate(1), ...current]; + assert.deepEqual(selectAttemptArtifacts(run(), all, 'candidate.yml'), current); + for (const missing of current) assert.throws(() => selectAttemptArtifacts(run(), all.filter(a => a !== missing), 'candidate.yml'), /Rerun all jobs/); + assert.throws(() => selectAttemptArtifacts(run(), candidate(1), 'candidate.yml'), /Rerun all jobs/); +}); +test('a full or smoke agent attempt must be complete even when earlier attempts succeeded', () => { + for (const matrix of ['smoke', 'full']) { + const names = (attempt) => AGENTS.flatMap(agent => (matrix === 'full' ? ['Linux'] : ['Linux', 'macOS', 'Windows']).map(os => `agent-${agent}-${os}-${matrix}-${attempt}`)); + const current = names(2).map((name, i) => artifact(name, 100 + i)); + const old = names(1).map((name, i) => artifact(name, 200 + i)); + assert.deepEqual(selectAttemptArtifacts(run('agent-e2e.yml'), [...old, ...current], 'agent-e2e.yml'), current); + assert.throws(() => selectAttemptArtifacts(run('agent-e2e.yml'), [...old, ...current.slice(1)], 'agent-e2e.yml'), /Rerun all jobs/); + } +}); +test('seal artifacts are selected by current attempt and original IDs stay distinct', () => { + const old = artifact('validated-release-1', 10), current = artifact('validated-release-2', 20); + assert.deepEqual(selectAttemptArtifacts(run('seal.yml'), [old, current], 'seal.yml'), [current]); + assert.throws(() => selectAttemptArtifacts(run('seal.yml'), [old], 'seal.yml'), /Rerun all jobs/); +}); +test('expired, duplicate, unrelated and changed artifact identities are rejected', () => { + const r = run(), current = candidate(2); + for (const change of [{ expired: true }, { id: null }, { digest: null }, { workflow_run: { id: 11, head_sha: r.head_sha } }]) assert.throws(() => assertArtifactIdentity({ ...current[0], ...change }, r)); + for (const change of [{ id: 999 }, { name: 'changed' }, { digest: 'sha256:' + 'c'.repeat(64) }]) assert.throws(() => assertArtifactIdentity({ ...current[0], ...change }, r, current[0]), /changed/); + assert.throws(() => selectAttemptArtifacts(r, [...current, current[0]], 'candidate.yml'), /Rerun all jobs/); +}); +test('resolve verifies current run before selecting paginated immutable artifacts', () => { + const calls = [], current = candidate(2), r = run(); + const api = ([route]) => { calls.push(route); if (!route.includes('artifacts?')) return r; return route.endsWith('page=1') ? { total_count: 8, artifacts: candidate(1) } : { total_count: 8, artifacts: current }; }; + assert.deepEqual(resolveAttempt('10', 'candidate.yml', r.head_sha, api).artifacts, current); + assert.equal(calls.length, 3); + for (const change of [{ run_attempt: undefined }, { conclusion: 'failure' }, { path: '.github/workflows/ci.yml' }, { head_sha: 'c'.repeat(40) }]) assert.throws(() => assertTrustedRun({ ...r, ...change }, 'candidate.yml', r.head_sha)); +}); diff --git a/tests/release-evidence.test.mjs b/tests/release-evidence.test.mjs new file mode 100644 index 0000000..9c6b192 --- /dev/null +++ b/tests/release-evidence.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildPackage } from '../scripts/package/build.mjs'; +import { checkPackage } from '../scripts/package/check.mjs'; +import { extractTarball, createTarball } from '../installer/lib/archive.mjs'; +import { validateReleasePackages, sourceIdentity, validateEvidence } from '../scripts/release/manifest.mjs'; +import { PRODUCTS, sha256, digest } from '../scripts/release/lib.mjs'; +import { outputFile } from '../scripts/agents/core.mjs'; +import { collectEvidenceClosure } from '../scripts/release/evidence.mjs'; + +function temp(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-evidence-unit-')); t.after(() => fs.rmSync(root, { force: true, recursive: true })); return root; } + +test('release verifies real archive product, version and all three source snapshots', t => { + const root = temp(t), artifacts = {}, snapshots = {}, fixtureVersion = '1.0.0-rc.1'; + for (const product of Object.keys(PRODUCTS)) { + const built = buildPackage({ product, out: root, version: fixtureVersion }); + artifacts[product] = { file: path.basename(built.tarball), sha256: built.sha256 }; + Object.assign(snapshots, checkPackage(built.tarball).snapshots); + } + const m = { schema_version: 1, version: fixtureVersion, channel: 'rc', state: 'prepared', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([product, p]) => [product, { repository: 'shendeguize/' + p.repo, commit: snapshots[p.repo], dirty: false }])), artifacts, evidence: [] }; + m.source_digest = sourceIdentity(m); + assert.equal(validateReleasePackages(m, root), true); + const swapped = structuredClone(m); swapped.artifacts.core = swapped.artifacts.advised; + assert.throws(() => validateReleasePackages(swapped, root), /product\/version/); + const wrongVersion = structuredClone(m); wrongVersion.version = '1.0.0-rc.2'; wrongVersion.source_digest = sourceIdentity(wrongVersion); + assert.throws(() => validateReleasePackages(wrongVersion, root), /product\/version/); + const wrongSource = structuredClone(m); wrongSource.sources.core.commit = '0'.repeat(40); wrongSource.source_digest = sourceIdentity(wrongSource); + assert.throws(() => validateReleasePackages(wrongSource, root), /snapshots differ/); + const text = outputFile(root, 'not-an-archive.tgz', 'Checksummed text is not a release package.'); + const invalid = structuredClone(m); invalid.artifacts.core = text; + assert.throws(() => validateReleasePackages(invalid, root)); + const unpacked = extractTarball(path.join(root, artifacts.core.file), path.join(root, 'unpacked')); + const manifestFile = path.join(unpacked, 'organon-content.json'), content = JSON.parse(fs.readFileSync(manifestFile)); + content.files.find(file => file.source).source.revision = 'f'.repeat(40); + fs.writeFileSync(manifestFile, JSON.stringify(content)); + assert.throws(() => checkPackage(unpacked), /provenance differs/); +}); + +test('opaque passed summaries cannot replace independently bound actual evidence', t => { + const root = temp(t); + const m = { schema_version: 1, version: '1.0.0-rc.1', channel: 'rc', state: 'prepared', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([product, p]) => [product, { repository: 'shendeguize/' + p.repo, commit: '1'.repeat(40), dirty: false }])), artifacts: {}, evidence: [] }; + m.source_digest = sourceIdentity(m); + const fake = outputFile(root, 'fabricated.json', { gate: 'agents', status: 'passed', source_digest: m.source_digest, artifact_digest: digest(m.artifacts), agent: 'codex', platform: 'linux', matrix: 'smoke', actual_call: true, independent_reviewer: true, model: 'unknown', tool_version: 'unknown' }); + m.evidence = [fake]; assert.throws(() => validateEvidence(m, root), /Invalid evidence reference/); +}); + +test('closure rejects changed nested objects and conflicting same-path identities', t => { + const root = temp(t), raw = outputFile(root, 'raw.txt', 'frozen'); + const report = outputFile(root, 'report.json', { nested: { raw } }); + assert.equal(collectEvidenceClosure({ evidence: [report] }, root).length, 2); + const conflict = outputFile(root, 'conflict.json', { a: raw, b: { file: raw.file, sha256: '0'.repeat(64) } }); + assert.throws(() => collectEvidenceClosure({ evidence: [conflict] }, root), /Conflicting/); + fs.rmSync(path.join(root, 'raw.txt')); + assert.throws(() => collectEvidenceClosure({ evidence: [report] }, root)); +}); diff --git a/tests/release-github-gate.test.mjs b/tests/release-github-gate.test.mjs new file mode 100644 index 0000000..0414863 --- /dev/null +++ b/tests/release-github-gate.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PRODUCTS, digest, sha256 } from '../scripts/release/lib.mjs'; +import { sourceIdentity, assertGithubReport, validateEvidence } from '../scripts/release/manifest.mjs'; +import { collectGithubReport } from '../scripts/release/candidate.mjs'; +import { recheckReleaseGovernance, withReleaseGovernance, verifyPublished } from '../scripts/release/publish.mjs'; +const candidate = () => { + const manifest = { schema_version: 1, version: '1.0.0-rc.1', channel: 'rc', state: 'prepared', artifacts: {}, evidence: [], sources: Object.fromEntries(Object.entries(PRODUCTS).map(([key, spec]) => [key, { repository: `shendeguize/${spec.repo}`, commit: 'a'.repeat(40), dirty: false }])) }; + manifest.source_digest = sourceIdentity(manifest); return manifest; +}; +const passing = repository => ({ status: 'passed', repository, rulesets: [{ id: 12, name: 'Unit fixture', bypass_actors: [] }], missing: [], security: { status: 'passed', repository, environments: [{ name: 'npm-stable', owner_approval: true }], actions: { sha_pinning_required: true }, workflow: { default_workflow_permissions: 'read' } } }); + +test('candidate report preserves full governance and security observations for the exact product', async () => { + const original = passing('shendeguize/OrganonCore'); + const result = await collectGithubReport('core', async repository => { assert.equal(repository, original.repository); return original; }); + assert.deepEqual(result, { ...original, gate: 'github', product: 'core' }); + assert.deepEqual(result.security.environments, original.security.environments); +}); +test('missing, failed and cross-repository security cannot become a passed GitHub gate', async () => { + const valid = passing('shendeguize/OrganonCore'); + const changes = [{ security: undefined }, { security: { ...valid.security, status: 'failed' } }, { security: { ...valid.security, repository: 'shendeguize/AdvisedOrganons' } }, { status: 'failed' }, { repository: 'shendeguize/AdvisedOrganons' }]; + for (const change of changes) { + const report = { ...valid, ...change }; + assert.throws(() => assertGithubReport(report, 'core'), /governance\/security/); + await assert.rejects(() => collectGithubReport('core', async () => report), /governance\/security/); + } + await assert.rejects(() => collectGithubReport('core', async () => { throw new Error('Missing audit credential'); }), /credential/); +}); +test('manifest validation rejects historical ruleset-only reports despite a valid checksum', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-github-gate-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const security of [undefined, { status: 'failed', repository: 'shendeguize/OrganonCore' }, { status: 'passed', repository: 'other/repo' }]) { + const manifest = candidate(), report = { ...passing('shendeguize/OrganonCore'), security, gate: 'github', product: 'core', source_digest: manifest.source_digest, artifact_digest: digest(manifest.artifacts) }; + const file = path.join(root, 'github.json'); fs.writeFileSync(file, JSON.stringify(report)); manifest.evidence = [{ file: 'github.json', sha256: sha256(fs.readFileSync(file)) }]; + assert.throws(() => validateEvidence(manifest, root), /governance\/security/); + } +}); +test('every operation freshly audits all three repositories before its callback', async () => { + const manifest = candidate(), order = []; + const inspect = async repository => { order.push(repository); return passing(repository); }; + await withReleaseGovernance(manifest, reports => { assert.equal(reports.length, 3); order.push('mutation'); }, inspect); + assert.deepEqual(order, [...Object.values(manifest.sources).map(source => source.repository), 'mutation']); + order.length = 0; + await withReleaseGovernance(manifest, () => order.push('mutation'), inspect); + assert.equal(order.length, 4); +}); +test('later governance drift or credential failure blocks publication instead of reusing old success', async () => { + const manifest = candidate(); let changed = false, mutations = 0; + const inspect = async repository => { const report = passing(repository); if (changed && repository.endsWith('/AdvisedOrganons')) report.security.status = 'failed'; return report; }; + await recheckReleaseGovernance(manifest, inspect); changed = true; + await assert.rejects(() => withReleaseGovernance(manifest, () => mutations++, inspect), /governance\/security/); + await assert.rejects(() => withReleaseGovernance(manifest, () => mutations++, async () => { throw new Error('Audit API denied'); }), /denied/); + assert.equal(mutations, 0); +}); +test('dual-channel verification checks all three governance states before artifact or registry access', async () => { + const manifest = candidate(), visited = []; + // Missing local directory/packages would fail later. Audit failure must win before any network/package access. + await assert.rejects(() => verifyPublished(manifest, '/missing-unit-fixture', ['core'], { inspect: async repository => { visited.push(repository); const report = passing(repository); if (repository.endsWith('/AgentOrganon')) report.security.status = 'failed'; return report; } }), /governance\/security/); + assert.deepEqual(visited, Object.values(manifest.sources).map(source => source.repository)); +}); diff --git a/tests/release-site-data.test.mjs b/tests/release-site-data.test.mjs new file mode 100644 index 0000000..23712b9 --- /dev/null +++ b/tests/release-site-data.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PRODUCTS, digest, writeJSON } from '../scripts/release/lib.mjs'; +import { sourceIdentity } from '../scripts/release/manifest.mjs'; +import { assertSiteRunner, inventory, validateState, sampleStars, installPublic, buildIdentity, verifyForSite, checkoutState } from '../scripts/release/site-data.mjs'; +const repository = 'shendeguize/AgentOrganon'; +function temporary(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'organon-site-state-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); return root; } +function manifest(version = '1.0.0-rc.1') { + const value = { schema_version: 1, version, channel: 'rc', state: 'validated', sources: Object.fromEntries(Object.entries(PRODUCTS).map(([key, p]) => [key, { repository: `shendeguize/${p.repo}`, commit: 'a'.repeat(40), dirty: false }])), evidence: [], artifacts: {} }; + value.source_digest = sourceIdentity(value); return value; +} +function built(t) { const root = temporary(t); fs.mkdirSync(path.join(root, 'zh')); fs.writeFileSync(path.join(root, 'index.html'), 'English released HTML'); fs.writeFileSync(path.join(root, 'zh/index.html'), '中文发行页面'); return root; } +const api = total => async () => ({ ok: true, json: async () => ({ stargazers_count: total }) }); +test('site writes reject local, foreign, PR and self-hosted contexts', () => { + const env = { GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted', GITHUB_REPOSITORY: repository, GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RUNNER_TEMP: '/tmp', GITHUB_TOKEN: 'fixture' }; + assert.doesNotThrow(() => assertSiteRunner(env, 'pages')); + for (const key of Object.keys(env)) assert.throws(() => assertSiteRunner({ ...env, [key]: '' }, 'pages')); + assert.throws(() => assertSiteRunner({ ...env, GITHUB_EVENT_NAME: 'schedule' }, 'pages')); + assert.doesNotThrow(() => assertSiteRunner({ ...env, GITHUB_EVENT_NAME: 'schedule' }, 'stars')); +}); +test('first actual sample does not create or deploy an unreleased site', async t => { + const root = temporary(t); + assert.equal(await sampleStars(root, repository, api(0), new Date('2026-09-15T00:00:00Z')), false); + assert.equal(validateState(root, repository), null); assert.equal(fs.existsSync(path.join(root, 'public')), false); + const before = fs.readFileSync(path.join(root, 'stars.json')); + await assert.rejects(sampleStars(root, repository, async () => ({ ok: false, status: 403 }), new Date('2026-09-16T00:00:00Z')), /state unchanged/); + assert.deepEqual(fs.readFileSync(path.join(root, 'stars.json')), before); + await assert.rejects(sampleStars(root, repository, api(undefined), new Date('2026-09-16T00:00:00Z')), /Invalid GitHub/); + assert.deepEqual(fs.readFileSync(path.join(root, 'stars.json')), before); +}); +test('daily sampling preserves exact released HTML while retaining decreases and gaps', async t => { + const root = temporary(t), source = built(t), release = manifest(); + installPublic(root, repository, release, source, '2026-09-14T00:00:00Z'); + const before = inventory(path.join(root, 'public')), receipt = fs.readFileSync(path.join(root, 'recommended-release.json')); + for (const [day, count] of [[15, 3], [16, 0], [19, 1]]) assert.equal(await sampleStars(root, repository, api(count), new Date(`2026-09-${day}T00:00:00Z`)), true); + assert.deepEqual(inventory(path.join(root, 'public')), before); assert.deepEqual(fs.readFileSync(path.join(root, 'recommended-release.json')), receipt); + const points = JSON.parse(fs.readFileSync(path.join(root, 'public/assets/stars.json'))).observations; + assert.deepEqual(points.map(p => p.total), [3,0,1]); + assert.equal((fs.readFileSync(path.join(root, 'public/assets/stars.svg'), 'utf8').match(/ observe(history, -1)); + assert.throws(() => observe(history, 0, new Date('2026-09-18T00:00:00Z'))); +}); +test('star gaps follow UTC calendar dates rather than elapsed hours', () => { + for (const [dates, segments] of [ + [['2026-09-14T23:30:00Z', '2026-09-16T00:30:00Z'], 0], + [['2026-09-14T00:01:00Z', '2026-09-15T23:59:00Z'], 1], + ]) { + const observations = dates.map((date, total) => ({ observed_at: new Date(date).toISOString(), total })); + const svg = renderSVG({ schema_version: 1, repository: 'shendeguize/AgentOrganon', observations }); + assert.equal((svg.match(/